{"repo":"NousResearch/hermes-agent","public_id":"36949","display_label":"1Password and Bitwarden secret source integrations","summary":"These issues and pull requests concern adding support for 1Password (via CLI, op:// URIs, and SDK) as a secret source backend, along with a pluggable SecretSource interface that also includes a Bitwarden provider.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":59498},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-06T11:58:07+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":33126,"pr_number":59498,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":36949,"pr_number":59498,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":36949,"title":"[Feature]: 1Password (op://) secret source backend","url":null,"state":"closed"},{"kind":"pr","number":32254,"title":"feat(secrets): add 1Password secret source","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59498","number":59498},{"kind":"pr","value":"#36896","number":36896}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":36896,"title":"feat(secrets): add 1Password (op://) secret source","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59498","number":59498}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":39445,"title":"feat(secrets): add 1Password SDK-based secret source (onepassword-sdk)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59498","number":59498},{"kind":"pr","value":"#36896","number":36896},{"kind":"pr","value":"#32254","number":32254}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":59498,"title":"feat(secrets): pluggable SecretSource interface + Bitwarden & 1Password providers","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":[".gitignore","agent/credential_pool.py","agent/secret_sources/__init__.py","agent/secret_sources/_cache.py","agent/secret_sources/base.py","agent/secret_sources/bitwarden.py","agent/secret_sources/onepassword.py","agent/secret_sources/registry.py","cli-config.yaml.example","hermes_cli/config.py","hermes_cli/env_loader.py","hermes_cli/main.py","hermes_cli/onepassword_secrets_cli.py","hermes_cli/plugins.py","scripts/release.py","tests/secret_sources/__init__.py","tests/secret_sources/conformance.py","tests/secret_sources/test_secret_source_registry.py","tests/test_bitwarden_secrets.py","tests/test_env_loader_op_bootstrap.py","tests/test_env_loader_secret_sources.py","tests/test_onepassword_secrets.py","website/docs/user-guide/secrets/index.md","website/docs/user-guide/secrets/onepassword.md"],"fix_hunks":[{"file":".gitignore","hunks":["@@ -8,6 +8,7 @@ __pycache__/\n .venv\n .vscode/\n .env\n+.op.env\n .env.local\n .env.development.local\n .env.test.local"]},{"file":"agent/credential_pool.py","hunks":["@@ -2105,8 +2105,20 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool\n     # changes to the .env file.\n     def _get_env_prefer_dotenv(key: str) -> str:\n         env_file = load_env()\n-        val = env_file.get(key) or _get_secret(key, \"\") or \"\"\n-        return val.strip()\n+        raw = env_file.get(key, \"\").strip()\n+        env_val = os.environ.get(key, \"\").strip()\n+        # If .env contains an unresolved op:// reference, prefer the\n+        # already-resolved value from os.environ (set by\n+        # load_hermes_dotenv() -> apply_onepassword_secrets()).  The raw\n+        # \"op://Vault/Item/field\" string would otherwise win and every\n+        # provider auth attempt would receive a URL instead of a key.  This\n+        # happens during a partial migration, or when the user wrote op://\n+        # references straight into .env rather than the secrets.onepassword\n+        # config block.  For every non-op:// value the original\n+        # .env-takes-precedence behaviour is preserved unchanged.\n+        if raw.startswith(\"op://\") and env_val:\n+            return env_val\n+        return raw or _get_secret(key, \"\") or env_val\n \n     # Honour user suppression — `hermes auth remove <provider> <N>` for an\n     # env-seeded credential marks the env:<VAR> source as suppressed so it"]},{"file":"agent/secret_sources/__init__.py","hunks":["@@ -1,13 +1,41 @@\n \"\"\"External secret source integrations.\n \n A secret source is anything that can supply environment-variable-shaped\n-credentials at process startup, _after_ ~/.hermes/.env has loaded.  By\n-default sources are non-destructive: they only set values for env vars\n-that aren't already present, so .env and shell exports continue to win.\n+credentials at process startup, _after_ ~/.hermes/.env has loaded.\n \n-Currently shipped:\n+The contract every source implements is\n+:class:`agent.secret_sources.base.SecretSource`; the orchestrator that\n+runs the enabled sources (ordering, mapped-beats-bulk precedence,\n+first-claim-wins conflicts, ``override_existing`` semantics, provenance)\n+is :func:`agent.secret_sources.registry.apply_all`.  Multiple sources\n+can be enabled at once — see the registry module docstring for the\n+precedence ladder.  The atomic-write / 0600 / TTL disk-cache substrate\n+is shared across backends in ``agent.secret_sources._cache`` so the\n+security-sensitive bits live in exactly one place.\n+\n+Currently bundled:\n \n   - ``bitwarden`` — Bitwarden Secrets Manager (`bws` CLI).  See\n     ``agent.secret_sources.bitwarden`` for the integration and\n     ``hermes_cli.secrets_cli`` for the user-facing setup wizard.\n+  - ``onepassword`` — 1Password ``op://`` secret references (`op` CLI).\n+    See ``agent.secret_sources.onepassword`` for the integration and\n+    ``hermes_cli.onepassword_secrets_cli`` for the user-facing commands.\n+\n+The bundled set is deliberately closed (policy mirrors memory\n+providers): new third-party secret managers ship as standalone plugin\n+repos that subclass ``SecretSource`` and register through\n+``PluginContext.register_secret_source()`` — they are NOT added to this\n+package.  A generic ``command`` source is a possible future exception;\n+OS keystores (Keychain/DPAPI/libsecret) are under discussion.\n \"\"\"\n+\n+from agent.secret_sources.base import (  # noqa: F401\n+    SECRET_SOURCE_API_VERSION,\n+    ErrorKind,\n+    FetchResult,\n+    SecretSource,\n+    is_valid_env_name,\n+    run_secret_cli,\n+    scrub_ansi,\n+)"]},{"file":"agent/secret_sources/_cache.py","hunks":["@@ -0,0 +1,213 @@\n+\"\"\"Shared substrate for external secret-source backends.\n+\n+Every backend (Bitwarden, 1Password, …) needs the same handful of\n+security-sensitive primitives:\n+\n+  * a uniform result object (:class:`FetchResult`),\n+  * environment-variable name validation (:func:`is_valid_env_name`),\n+  * a two-layer fetch cache whose disk half writes atomically with ``0600``\n+    permissions and honours a TTL (:class:`DiskCache`, :class:`CachedFetch`).\n+\n+These used to live inline inside ``bitwarden.py``.  Pulling them here means\n+the atomic-write / ``0600`` / TTL logic is audited and fixed in exactly one\n+place instead of drifting across copy-pasted per-backend modules — each\n+backend supplies only its own cache-key shape and a serializer for it.\n+\n+Nothing in this module ever raises out to the caller's hot path: the disk\n+layer is strictly best-effort (a miss just triggers a refetch), because a\n+cache problem must never block Hermes startup.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import os\n+import tempfile\n+import time\n+from dataclasses import dataclass\n+from pathlib import Path\n+from typing import Callable, Dict, Generic, Optional, TypeVar\n+\n+__all__ = [\n+    \"FetchResult\",\n+    \"CachedFetch\",\n+    \"DiskCache\",\n+    \"is_valid_env_name\",\n+    \"resolve_cache_home\",\n+]\n+\n+\n+# ---------------------------------------------------------------------------\n+# Result object + env-name validation — canonical definitions live in\n+# ``agent.secret_sources.base`` (the SecretSource contract module); re-exported\n+# here so backends that import from ``_cache`` keep working.\n+# ---------------------------------------------------------------------------\n+\n+from agent.secret_sources.base import (  # noqa: E402\n+    FetchResult,\n+    is_valid_env_name,\n+)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Cache entry\n+# ---------------------------------------------------------------------------\n+\n+\n+@dataclass\n+class CachedFetch:\n+    \"\"\"A set of fetched secret values plus when they were fetched.\"\"\"\n+\n+    secrets: [redacted:secret-assignment] str]\n+    fetched_at: float\n+\n+    def is_fresh(self, ttl_seconds: float) -> bool:\n+        if ttl_seconds <= 0:\n+            return False\n+        return (time.time() - self.fetched_at) < ttl_seconds\n+\n+\n+\n+\n+# ---------------------------------------------------------------------------\n+# Disk cache\n+# ---------------------------------------------------------------------------\n+\n+\n+def resolve_cache_home(home_path: Optional[Path] = None) -> Path:\n+    \"\"\"Resolve the Hermes home used for cache paths.\n+\n+    ``home_path`` is whatever ``load_hermes_dotenv()`` already resolved;\n+    falling back to ``$HERMES_HOME`` / ``~/.hermes`` keeps direct callers\n+    (and tests that don't thread a home through) working.\n+    \"\"\"\n+    if home_path is None:\n+        home_path = Path(os.getenv(\"HERMES_HOME\", Path.home() / \".hermes\"))\n+    return home_path\n+\n+\n+K = TypeVar(\"K\")\n+\n+\n+class DiskCache(Generic[K]):\n+    \"\"\"Best-effort, profile-aware on-disk cache for fetched secret values.\n+\n+    One JSON object per backend lives at ``<hermes_home>/cache/<basename>``::\n+\n+        {\"key\": \"<serialized cache key>\", \"secrets\": {...}, \"fetched_at\": 1.0}\n+\n+    The file holds only secret *values* keyed by the serialized cache key —\n+    never raw auth material.  Backends are responsible for fingerprinting\n+    tokens/sessions *before* they reach ``key_serializer`` so the token can't\n+    land in the key.\n+\n+    Writes are atomic (``mkstemp`` → ``chmod 0600`` → ``os.replace``) and the\n+    containing ``cache/`` directory is forced to ``0700`` — ``mkdir``'s mode is\n+    umask-subject, so the chmod is the reliable form.  Both ``read`` and\n+    ``write`` short-circuit when ``ttl_seconds <= 0``, so setting the TTL to\n+    zero disables *both* cache layers symmetrically: a user opting out never\n+    gets secret values written to disk at all.\n+    \"\"\"\n+\n+    def __init__(self, basename: str, *, key_serializer: Callable[[K], str]) -> None:\n+        self._basename = basename\n+        self._key_serializer = key_serializer\n+        # Temp-file prefix derived from the basename so concurrent writers for\n+        # different backends in the same dir don't collide on the staging name.\n+        stem = basename.split(\".\", 1)[0]\n+        self._tmp_prefix = f\".{stem}_\"\n+\n+    def path(self, home_path: Optional[Path] = None) -> Path:\n+        return resolve_cache_home(home_path) / \"cache\" / self._basename\n+\n+    def read(\n+        self,\n+        key: K,\n+        ttl_seconds: float,\n+        home_path: Optional[Path] = None,\n+    ) -> Optional[CachedFetch]:\n+        \"\"\"Return a fresh cached entry for ``key``, or None.\n+\n+        Best-effort: any I/O or parse error, a key mismatch, or a stale entry\n+        all return None so the caller re-fetches.\n+        \"\"\"\n+        if ttl_seconds <= 0:\n+            return None\n+        path = self.path(home_path)\n+        try:\n+            with open(path, \"r\", encoding=\"utf-8\") as f:\n+                payload = json.load(f)\n+        except (OSError, json.JSONDecodeError):\n+            return None\n+        if not isinstance(payload, dict):\n+            return None\n+        if payload.get(\"key\") != self._key_serializer(key):\n+            return None\n+        secrets = [redacted:secret-assignment]\"secrets\")\n+        fetched_at = payload.get(\"fetched_at\")\n+        if not isinstance(secrets, dict) or not isinstance(fetched_at, (int, float)):\n+            return None\n+        # JSON permits non-string values; env vars need strings, so coerce by\n+        # dropping anything that isn't a str→str pair.\n+        typed: Dict[str, str] = {\n+            k: v for k, v in secrets.items() if isinstance(k, str) and isinstance(v, str)\n+        }\n+        entry = CachedFetch(secrets=[redacted:secret-assignment] fetched_at=float(fetched_at))\n+        if not entry.is_fresh(ttl_seconds):\n+            return None\n+        return entry\n+\n+    def write(\n+        self,\n+        key: K,\n+        entry: CachedFetch,\n+        ttl_seconds: float,\n+        home_path: Optional[Path] = None,\n+    ) -> None:\n+        \"\"\"Persist ``entry`` for ``key`` atomically at mode ``0600``.\n+\n+        No-op when ``ttl_seconds <= 0`` (so caching is genuinely off) or on any\n+        I/O error — the next invocation just re-fetches.\n+        \"\"\"\n+        if ttl_seconds <= 0:\n+            return\n+        path = self.path(home_path)\n+        try:\n+            cache_dir = path.parent\n+            cache_dir.mkdir(parents=True, exist_ok=True)\n+            # mkdir's mode is umask-subject; chmod the dir to 0700 so cache\n+            # metadata isn't exposed if HERMES_HOME is ever made traversable.\n+            try:\n+                os.chmod(cache_dir, 0o700)\n+            except OSError:\n+                pass\n+            payload = {\n+                \"key\": self._key_serializer(key),\n+                \"secrets\": entry.secrets,\n+                \"fetched_at\": entry.fetched_at,\n+            }\n+            # Write to a sibling temp file and atomic-rename.  tempfile honours\n+            # os.umask, so we explicitly chmod 0600 before the rename.\n+            fd, tmp = tempfile.mkstemp(\n+                prefix=self._tmp_prefix, suffix=\".tmp\", dir=str(cache_dir)\n+            )\n+            try:\n+                with os.fdopen(fd, \"w\", encoding=\"utf-8\") as f:\n+                    json.dump(payload, f)\n+                os.chmod(tmp, 0o600)\n+                os.replace(tmp, path)\n+            except BaseException:\n+                try:\n+                    os.unlink(tmp)\n+                except OSError:\n+                    pass\n+                raise\n+        except OSError:\n+            pass  # best-effort — a disk-cache miss next invocation is fine\n+\n+    def clear(self, home_path: Optional[Path] = None) -> None:\n+        \"\"\"Delete the on-disk cache file if present (idempotent).\"\"\"\n+        try:\n+            self.path(home_path).unlink()\n+        except (FileNotFoundError, OSError):\n+            pass"]},{"file":"agent/secret_sources/base.py","hunks":["@@ -0,0 +1,274 @@\n+\"\"\"Secret-source contract: the ABC every secret backend implements.\n+\n+A *secret source* resolves credentials from an external secret manager\n+(Bitwarden Secrets Manager, 1Password, an OS keystore, a user script, ...)\n+into environment-variable-shaped values at process startup, AFTER\n+``~/.hermes/.env`` has loaded and BEFORE the rest of Hermes reads\n+``os.environ``.\n+\n+Scope of the contract (deliberate, please do not widen):\n+\n+* **Read-only.**  Sources resolve refs → values.  There is no write-back\n+  (\"save this key to your vault\"), no arbitrary secret objects, and no\n+  mid-session secret API.  If a future need for rotation/refresh appears\n+  it will arrive as a versioned optional hook — do not bolt it on.\n+* **Startup-time, synchronous.**  ``fetch()`` is called once per process\n+  (per HERMES_HOME) by the orchestrator in\n+  :mod:`agent.secret_sources.registry`, which enforces a wall-clock\n+  timeout around it.  Sources must not spawn background refreshers.\n+* **Never raises, never prompts.**  ``fetch()`` returns a\n+  :class:`FetchResult` — errors go in ``result.error`` with a\n+  machine-readable :class:`ErrorKind`.  Interactive auth belongs in the\n+  source's CLI ``setup`` flow, never on the startup path (non-TTY\n+  gateway/cron startup must never block on stdin).\n+* **Sources fetch; the orchestrator applies.**  A source returns the\n+  name→value mapping it *would* contribute.  Precedence (mapped-beats-bulk,\n+  first-wins, ``override_existing``, protected vars), conflict warnings,\n+  provenance tracking, and the actual ``os.environ`` writes are owned by\n+  the orchestrator so no backend can get them wrong.\n+\n+Versioning: ``SECRET_SOURCE_API_VERSION`` gates plugin compatibility.\n+New *optional* hooks with default implementations do not bump it;\n+required-signature changes do, and the registry skips (with a warning)\n+sources built against a different major version instead of crashing\n+startup.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+import re\n+import subprocess\n+from abc import ABC, abstractmethod\n+from dataclasses import dataclass, field\n+from enum import Enum\n+from pathlib import Path\n+from typing import Dict, FrozenSet, List, Optional, Sequence\n+\n+# Bump ONLY for breaking changes to the required contract surface\n+# (abstract-method signatures, FetchResult required fields).  Additive\n+# optional hooks must ship with defaults and must NOT bump this.\n+SECRET_SOURCE_API_VERSION = 1\n+\n+# Timeout the orchestrator enforces around fetch() when the source's\n+# config section doesn't override it.  Generous because a first run may\n+# include a one-time CLI binary auto-install (e.g. bws download+verify).\n+DEFAULT_FETCH_TIMEOUT_SECONDS = 120.0\n+\n+# Default timeout for run_secret_cli() subprocess invocations.\n+DEFAULT_CLI_TIMEOUT_SECONDS = 30.0\n+\n+\n+class ErrorKind(str, Enum):\n+    \"\"\"Machine-readable failure taxonomy for :class:`FetchResult.error`.\n+\n+    A fixed vocabulary keeps startup warnings and ``hermes secrets status``\n+    uniform across backends, and lets the orchestrator implement\n+    kind-dependent policy (e.g. a future stale-cache fallback on\n+    ``NETWORK``/``TIMEOUT`` but not on ``AUTH_FAILED``) exactly once.\n+    \"\"\"\n+\n+    NOT_CONFIGURED = \"not_configured\"    # enabled but missing token/project/map\n+    BINARY_MISSING = \"binary_missing\"    # helper CLI not found / not installed\n+    AUTH_FAILED = \"auth_failed\"          # bad credentials\n+    AUTH_EXPIRED = \"auth_expired\"        # credentials were valid, aren't now\n+    REF_INVALID = \"ref_invalid\"          # a secret reference failed validation\n+    NETWORK = \"network\"                  # transport-level failure\n+    EMPTY_VALUE = \"empty_value\"          # backend returned nothing for a ref\n+    TIMEOUT = \"timeout\"                  # fetch exceeded its wall-clock budget\n+    INTERNAL = \"internal\"                # anything else (bug, unexpected shape)\n+\n+\n+@dataclass\n+class FetchResult:\n+    \"\"\"Outcome of one source's fetch.\n+\n+    ``secrets`` holds what the source *would* contribute; whether each\n+    var is actually applied is the orchestrator's decision.  ``applied``\n+    and ``skipped`` exist for backward compatibility with the original\n+    Bitwarden fetch-and-apply entry point and are left empty by\n+    conforming ``fetch()`` implementations.\n+    \"\"\"\n+\n+    secrets: [redacted:secret-assignment] str] = field(default_factory=dict)\n+    applied: List[str] = field(default_factory=list)\n+    skipped: List[str] = field(default_factory=list)\n+    warnings: List[str] = field(default_factory=list)\n+    error: Optional[str] = None\n+    error_kind: Optional[ErrorKind] = None\n+    # Path of the helper binary used, when the source is CLI-driven.\n+    # Surfaced by status commands; None for SDK/API-driven sources.\n+    binary_path: Optional[Path] = None\n+\n+    @property\n+    def ok(self) -> bool:\n+        return self.error is None\n+\n+\n+class SecretSource(ABC):\n+    \"\"\"One external secret backend.\n+\n+    Subclasses set the class attributes and implement :meth:`fetch`.\n+    Everything else has a sensible default.\n+\n+    Attributes:\n+        name: Config-section key under ``secrets:`` in config.yaml.\n+            Lowercase ``[a-z0-9_]+``.  Also the provenance label stored\n+            for every var this source supplies.\n+        label: Human-readable name used in startup messages and\n+            ``hermes secrets status`` (e.g. ``\"Bitwarden Secrets Manager\"``).\n+        shape: ``\"mapped\"`` when the user explicitly binds env-var names\n+            to refs (1Password ``env:`` map, command source) or\n+            ``\"bulk\"`` when the backend injects whole projects/folders\n+            of secrets implicitly (Bitwarden BSM).  The orchestrator\n+            gives mapped sources precedence over bulk sources: an\n+            explicit binding is stronger intent than a project dump.\n+        scheme: Optional URI scheme this source owns for secret\n+            references (``\"op\"`` for ``op://...``).  Must be unique\n+            across registered sources — refs may eventually appear\n+            outside the ``secrets:`` block (e.g. credential-pool\n+            ``api_key`` fields), so scheme collisions are rejected at\n+            registration time to keep that future possible.\n+        api_version: Contract version this source was built against.\n+    \"\"\"\n+\n+    api_version: int = SECRET_SOURCE_API_VERSION\n+    name: str = \"\"\n+    label: str = \"\"\n+    shape: str = \"mapped\"  # \"mapped\" | \"bulk\"\n+    scheme: Optional[str] = None\n+\n+    # -- required ----------------------------------------------------------\n+\n+    @abstractmethod\n+    def fetch(self, cfg: dict, home_path: Path) -> FetchResult:\n+        \"\"\"Resolve this source's secrets. MUST NOT raise or prompt.\n+\n+        ``cfg`` is the source's raw config section (``secrets.<name>``)\n+        from config.yaml — treat every field defensively, the section\n+        may be malformed.  ``home_path`` is the resolved HERMES_HOME.\n+        \"\"\"\n+\n+    # -- optional hooks (defaults are correct for most sources) ------------\n+\n+    def is_enabled(self, cfg: dict) -> bool:\n+        \"\"\"Whether the user turned this source on.\"\"\"\n+        return bool(isinstance(cfg, dict) and cfg.get(\"enabled\"))\n+\n+    def override_existing(self, cfg: dict) -> bool:\n+        \"\"\"May this source overwrite vars that .env / the shell already set?\n+\n+        This NEVER extends to vars claimed by another secret source in the\n+        same startup pass — cross-source overrides are a config error the\n+        orchestrator warns about, not a knob.\n+        \"\"\"\n+        return bool(isinstance(cfg, dict) and cfg.get(\"override_existing\", False))\n+\n+    def protected_env_vars(self, cfg: dict) -> FrozenSet[str]:\n+        \"\"\"Env vars the orchestrator must never let ANY source overwrite.\n+\n+        Typically the source's own bootstrap-auth var (e.g.\n+        ``BWS_ACCESS_TOKEN``) so a vault that contains its own access\n+        token can't clobber the credential used to reach it.\n+        \"\"\"\n+        return frozenset()\n+\n+    def fetch_timeout_seconds(self, cfg: dict) -> float:\n+        \"\"\"Wall-clock budget the orchestrator enforces around fetch().\"\"\"\n+        try:\n+            val = float((cfg or {}).get(\"timeout_seconds\", DEFAULT_FETCH_TIMEOUT_SECONDS))\n+        except (TypeError, ValueError):\n+            return DEFAULT_FETCH_TIMEOUT_SECONDS\n+        return val if val > 0 else DEFAULT_FETCH_TIMEOUT_SECONDS\n+\n+    def config_schema(self) -> dict:\n+        \"\"\"Optional description of this source's config keys.\n+\n+        Shape: ``{key: {\"description\": str, \"default\": Any}}``.  Used by\n+        setup surfaces to render config without hardcoding per-source\n+        knowledge.  Purely informational.\n+        \"\"\"\n+        return {}\n+\n+\n+# ---------------------------------------------------------------------------\n+# Shared helpers — use these instead of hand-rolling per backend\n+# ---------------------------------------------------------------------------\n+\n+\n+_ENV_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n+\n+# ANSI CSI/OSC escape sequences — helper-CLI stderr often carries color\n+# codes that must not reach Hermes' own startup output.\n+_ANSI_RE = re.compile(r\"\\x1b(?:\\[[0-9;?]*[ -/]*[@-~]|\\][^\\x07\\x1b]*(?:\\x07|\\x1b\\\\)?)\")\n+\n+\n+def is_valid_env_name(name: str) -> bool:\n+    \"\"\"True when ``name`` is a legal environment-variable name.\"\"\"\n+    return bool(name) and bool(_ENV_NAME_RE.match(name))\n+\n+\n+def scrub_ansi(text: str) -> str:\n+    \"\"\"Strip ANSI escape sequences (whole CSI/OSC sequences, not just ESC).\"\"\"\n+    return _ANSI_RE.sub(\"\", text or \"\")\n+\n+\n+def run_secret_cli(\n+    argv: Sequence[str],\n+    *,\n+    allow_env: Sequence[str] = (),\n+    extra_env: Optional[Dict[str, str]] = None,\n+    timeout: float = DEFAULT_CLI_TIMEOUT_SECONDS,\n+) -> subprocess.CompletedProcess:\n+    \"\"\"Run a secret-manager helper CLI with a minimal, allowlisted env.\n+\n+    Security posture shared by every subprocess-driven backend:\n+\n+    * argv list only — never ``shell=True``.  Callers pass user-supplied\n+      reference strings AFTER a ``--`` option terminator in their argv.\n+    * The child gets ``PATH``/``HOME``/locale basics plus only the env\n+      vars named in ``allow_env`` (auth/session vars) and ``extra_env``\n+      — never a copy of the full post-dotenv ``os.environ``, which by\n+      this point holds every credential Hermes knows about.\n+    * ``NO_COLOR=1`` is set and stderr/stdout are ANSI-scrubbed so\n+      helper diagnostics can't smuggle escape sequences into Hermes\n+      output.\n+    * stdin is ``/dev/null`` so a helper that decides to prompt fails\n+      fast instead of hanging startup.\n+\n+    Raises ``RuntimeError`` on spawn failure or timeout (message safe to\n+    surface); returns the completed process otherwise — callers own\n+    returncode interpretation.\n+    \"\"\"\n+    base_keep = (\"PATH\", \"HOME\", \"USERPROFILE\", \"SYSTEMROOT\", \"TMPDIR\", \"TEMP\",\n+                 \"LANG\", \"LC_ALL\", \"XDG_CONFIG_HOME\", \"XDG_DATA_HOME\")\n+    env: Dict[str, str] = {}\n+    for key in (*base_keep, *allow_env):\n+        val = os.environ.get(key)\n+        if val is not None:\n+            env[key] = val\n+    if extra_env:\n+        env.update(extra_env)\n+    env.setdefault(\"NO_COLOR\", \"1\")\n+\n+    try:\n+        proc = subprocess.run(  # noqa: S603 — argv list, no shell\n+            list(argv),\n+            env=env,\n+            capture_output=True,\n+            text=True,\n+            timeout=timeout,\n+            stdin=subprocess.DEVNULL,\n+        )\n+    except subprocess.TimeoutExpired as exc:\n+        raise RuntimeError(\n+            f\"{Path(str(argv[0])).name} timed out after {timeout:.0f}s\"\n+        ) from exc\n+    except OSError as exc:\n+        raise RuntimeError(\n+            f\"failed to invoke {Path(str(argv[0])).name}: {exc}\"\n+        ) from exc\n+\n+    proc.stdout = proc.stdout or \"\"\n+    proc.stderr = scrub_ansi(proc.stderr or \"\")\n+    return proc"]},{"file":"agent/secret_sources/bitwarden.py","hunks":["@@ -42,10 +42,17 @@\n import urllib.error\n import urllib.request\n import zipfile\n-from dataclasses import dataclass, field\n from pathlib import Path\n from typing import Dict, List, Optional, Tuple\n \n+from agent.secret_sources._cache import (\n+    CachedFetch as _CachedFetch,\n+    DiskCache,\n+    FetchResult,\n+    is_valid_env_name as _is_valid_env_name,\n+)\n+from agent.secret_sources.base import ErrorKind, SecretSource\n+\n logger = logging.getLogger(__name__)\n \n ","@@ -70,7 +77,7 @@\n # In-process cache so repeated load_hermes_dotenv() calls (CLI startup,\n # gateway hot-reload, test suites) don't re-fetch from BSM.\n _CacheKey = Tuple[str, str, str]  # (access_token_fingerprint, project_id, server_url)\n-_CACHE: Dict[_CacheKey, \"_CachedFetch\"] = {}\n+_CACHE: Dict[_CacheKey, _CachedFetch] = {}\n \n # Disk-persisted cache so back-to-back CLI invocations (e.g. `hermes chat -q ...`\n # called from scripts, cron, the gateway forking new agents) don't each pay the","@@ -81,124 +88,29 @@\n # <hermes_home>/cache/bws_cache.json. The file holds only the secret VALUES,\n # never the access token. It's plaintext-equivalent to ~/.hermes/.env (which\n # we already accept) but kept out of the .env file so users editing it won't\n-# accidentally commit BSM-sourced secrets.\n+# accidentally commit BSM-sourced secrets. The atomic-write/0600/TTL mechanics\n+# live in agent.secret_sources._cache.DiskCache, shared with the other backends.\n _DISK_CACHE_BASENAME = \"bws_cache.json\"\n \n \n-def _disk_cache_path(home_path: Optional[Path] = None) -> Path:\n-    \"\"\"Return the disk cache path under hermes_home/cache/.\n-\n-    `home_path` is what `load_hermes_dotenv()` already resolved; falling back\n-    to `$HERMES_HOME` / `~/.hermes` keeps direct callers working too.\n-    \"\"\"\n-    if home_path is None:\n-        home_path = Path(os.getenv(\"HERMES_HOME\", Path.home() / \".hermes\"))\n-    return home_path / \"cache\" / _DISK_CACHE_BASENAME\n-\n-\n def _cache_key_str(cache_key: _CacheKey) -> str:\n     \"\"\"Serialize a cache key to a stable string for JSON storage.\"\"\"\n     token_fp, project_id, server_url = cache_key\n     return f\"{token_fp}|{project_id}|{server_url}\"\n \n \n-def _read_disk_cache(cache_key: _CacheKey, ttl_seconds: float,\n-                     home_path: Optional[Path] = None) -> Optional[\"_CachedFetch\"]:\n-    \"\"\"Return a cached entry from disk if fresh, else None.\n-\n-    Best-effort: any I/O or parse error returns None and we re-fetch.\n-    \"\"\"\n-    if ttl_seconds <= 0:\n-        return None\n-    path = _disk_cache_path(home_path)\n-    try:\n-        with open(path, \"r\", encoding=\"utf-8\") as f:\n-            payload = json.load(f)\n-    except (OSError, json.JSONDecodeError):\n-        return None\n-    if not isinstance(payload, dict):\n-        return None\n-    if payload.get(\"key\") != _cache_key_str(cache_key):\n-        return None\n-    secrets = [redacted:secret-assignment]\"secrets\")\n-    fetched_at = payload.get(\"fetched_at\")\n-    if not isinstance(secrets, dict) or not isinstance(fetched_at, (int, float)):\n-        return None\n-    # Coerce all values to strings — JSON allows numbers but env vars need strings\n-    typed_secrets: [redacted:secret-assignment] str] = {\n-        k: v for k, v in secrets.items() if isinstance(k, str) and isinstance(v, str)\n-    }\n-    entry = _CachedFetch(secrets=[redacted:secret-assignment] fetched_at=float(fetched_at))\n-    if not entry.is_fresh(ttl_seconds):\n-        return None\n-    return entry\n-\n-\n-def _write_disk_cache(cache_key: _CacheKey, entry: \"_CachedFetch\",\n-                      home_path: Optional[Path] = None) -> None:\n-    \"\"\"Persist a cache entry to disk atomically with mode 0600.\n-\n-    Best-effort: any I/O error is swallowed (the next invocation will just\n-    re-fetch). We never want disk cache failures to break startup.\n-    \"\"\"\n-    path = _disk_cache_path(home_path)\n-    try:\n-        path.parent.mkdir(parents=True, exist_ok=True)\n-        payload = {\n-            \"key\": _cache_key_str(cache_key),\n-            \"secrets\": entry.secrets,\n-            \"fetched_at\": entry.fetched_at,\n-        }\n-        # Write to a temp file in the same directory and atomic-rename.\n-        # tempfile honors os.umask, so we explicitly chmod 0600 before rename.\n-        fd, tmp = tempfile.mkstemp(\n-            prefix=\".bws_cache_\", suffix=\".tmp\", dir=str(path.parent)\n-        )\n-        try:\n-            with os.fdopen(fd, \"w\", encoding=\"utf-8\") as f:\n-                json.dump(payload, f)\n-            os.chmod(tmp, 0o600)\n-            os.replace(tmp, path)\n-        except BaseException:\n-            try:\n-                os.unlink(tmp)\n-            except OSError:\n-                pass\n-            raise\n-    except OSError:\n-        pass  # best-effort — disk cache miss on next invocation is fine\n-\n-\n-@dataclass\n-class _CachedFetch:\n-    secrets: [redacted:secret-assignment] str]\n-    fetched_at: float\n-\n-    def is_fresh(self, ttl_seconds: float) -> bool:\n-        if ttl_seconds <= 0:\n-            return False\n-        return (time.time() - self.fetched_at) < ttl_seconds\n-\n-\n-# ---------------------------------------------------------------------------\n-# Public dataclasses\n-# ---------------------------------------------------------------------------\n-\n+_DISK_CACHE: DiskCache = DiskCache(\n+    _DISK_CACHE_BASENAME, key_serializer=_cache_key_str\n+)\n \n-@dataclass\n-class FetchResult:\n-    \"\"\"Outcome of a single BSM pull.\"\"\"\n \n-    secrets: [redacted:secret-assignment] str] = field(default_factory=dict)\n-    applied: List[str] = field(default_factory=list)   # set into os.environ\n-    skipped: List[str] = field(default_factory=list)   # already set, not overridden\n-    warnings: List[str] = field(default_factory=list)  # non-fatal issues\n-    error: Optional[str] = None                        # fatal: nothing was fetched\n-    binary_path: Optional[Path] = None\n+def _disk_cache_path(home_path: Optional[Path] = None) -> Path:\n+    \"\"\"Return the disk cache path under hermes_home/cache/.\n \n-    @property\n-    def ok(self) -> bool:\n-        return self.error is None\n+    Thin wrapper over the shared DiskCache, kept for tests and any direct\n+    callers; falls back to `$HERMES_HOME` / `~/.hermes` when home is None.\n+    \"\"\"\n+    return _DISK_CACHE.path(home_path)\n \n \n # ---------------------------------------------------------------------------","@@ -479,7 +391,7 @@ def fetch_bitwarden_secrets(\n         if cached and cached.is_fresh(cache_ttl_seconds):\n             return cached.secrets, []\n         # L2: disk cache. ~5ms on cache hit vs ~380ms for `bws secret list`.\n-        disk_cached = _read_disk_cache(cache_key, cache_ttl_seconds, home_path)\n+        disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)\n         if disk_cached is not None:\n             # Promote into in-process cache so subsequent fetches in the\n             # same process skip the disk read too.","@@ -499,7 +411,7 @@ def fetch_bitwarden_secrets(\n     entry = _CachedFetch(secrets=[redacted:secret-assignment] fetched_at=time.time())\n     _CACHE[cache_key] = entry\n     if use_cache:\n-        _write_disk_cache(cache_key, entry, home_path)\n+        _DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)\n     return secrets, warnings\n \n ","@@ -575,14 +487,6 @@ def _run_bws_list(\n     return secrets, warnings\n \n \n-def _is_valid_env_name(name: str) -> bool:\n-    if not name:\n-        return False\n-    if not (name[0].isalpha() or name[0] == \"_\"):\n-        return False\n-    return all(c.isalnum() or c == \"_\" for c in name)\n-\n-\n # ---------------------------------------------------------------------------\n # Public entry point — called from hermes_cli.env_loader\n # ---------------------------------------------------------------------------","@@ -673,6 +577,142 @@ def apply_bitwarden_secrets(\n     return result\n \n \n+# ---------------------------------------------------------------------------\n+# SecretSource adapter — the registry-facing wrapper around this module.\n+# ---------------------------------------------------------------------------\n+\n+\n+class BitwardenSource(SecretSource):\n+    \"\"\"Bitwarden Secrets Manager as a registered secret source.\n+\n+    Thin adapter over the module's fetch machinery.  ``fetch()`` only\n+    *fetches* — precedence, override semantics, conflict warnings, and\n+    the ``os.environ`` writes are the orchestrator's job\n+    (see ``agent.secret_sources.registry.apply_all``).\n+\n+    Bitwarden is a **bulk** source: it injects every secret in the\n+    configured BSM project, so explicit per-var bindings from mapped\n+    sources (e.g. the 1Password ``env:`` map) outrank it.\n+    \"\"\"\n+\n+    name = \"bitwarden\"\n+    label = \"Bitwarden Secrets Manager\"\n+    shape = \"bulk\"\n+    scheme = \"bws\"\n+\n+    def override_existing(self, cfg: dict) -> bool:\n+        # Default True (matches DEFAULT_CONFIG): the point of BSM is\n+        # centralized rotation — if .env had the final say, rotating a\n+        # key in Bitwarden wouldn't take effect until the stale .env\n+        # line was also deleted.\n+        return bool(isinstance(cfg, dict) and cfg.get(\"override_existing\", True))\n+\n+    def protected_env_vars(self, cfg: dict):\n+        token_env = \"[redacted:secret-assignment]\"\n+        if isinstance(cfg, dict):\n+            token_env = [redacted:secret-assignment]\"access_token_env\") or token_env)\n+        return frozenset({token_env})\n+\n+    def config_schema(self) -> dict:\n+        return {\n+            \"enabled\": {\"description\": \"Master switch\", \"default\": False},\n+            \"access_token_env\": {\n+                \"description\": \"Env var holding the machine-account access token\",\n+                \"default\": \"BWS_ACCESS_TOKEN\",\n+            },\n+            \"project_id\": {\"description\": \"BSM project UUID\", \"default\": \"\"},\n+            \"cache_ttl_seconds\": {\n+                \"description\": \"Disk+memory cache TTL; 0 disables\",\n+                \"default\": 300,\n+            },\n+            \"override_existing\": {\n+                \"description\": \"BSM values overwrite .env/shell values\",\n+                \"default\": True,\n+            },\n+            \"auto_install\": {\n+                \"description\": \"Auto-download the pinned bws binary\",\n+                \"default\": True,\n+            },\n+            \"server_url\": {\n+                \"description\": \"Region / self-hosted endpoint (empty = US Cloud)\",\n+                \"default\": \"\",\n+            },\n+        }\n+\n+    def fetch(self, cfg: dict, home_path: Path) -> FetchResult:\n+        cfg = cfg if isinstance(cfg, dict) else {}\n+        result = FetchResult()\n+\n+        access_token_env = [redacted:secret-assignment]\"access_token_env\") or \"BWS_ACCESS_TOKEN\")\n+        access_token = [redacted:secret-assignment] \"\").strip()\n+        if not access_token:\n+            result.error = (\n+                f\"secrets.bitwarden.enabled is true but {access_token_env} is \"\n+                \"not set.  Run `hermes secrets bitwarden setup`.\"\n+            )\n+            result.error_kind = ErrorKind.NOT_CONFIGURED\n+            return result\n+\n+        project_id = str(cfg.get(\"project_id\") or \"\")\n+        if not project_id:\n+            result.error = (\n+                \"secrets.bitwarden.project_id is empty.  \"\n+                \"Run `hermes secrets bitwarden setup`.\"\n+            )\n+            result.error_kind = ErrorKind.NOT_CONFIGURED\n+            return result\n+\n+        auto_install = bool(cfg.get(\"auto_install\", True))\n+        binary = find_bws(install_if_missing=auto_install)\n+        result.binary_path = binary\n+        if binary is None:\n+            result.error = (\n+                \"bws binary not available and auto-install is disabled.  \"\n+                \"Run `hermes secrets bitwarden setup` to install.\"\n+            )\n+            result.error_kind = ErrorKind.BINARY_MISSING\n+            return result\n+\n+        try:\n+            ttl = float(cfg.get(\"cache_ttl_seconds\", 300))\n+        except (TypeError, ValueError):\n+            ttl = 300.0\n+\n+        try:\n+            secrets, warnings = fetch_bitwarden_secrets(\n+                access_token=[redacted:secret-assignment]\n+                project_id=project_id,\n+                binary=binary,\n+                cache_ttl_seconds=ttl,\n+                server_url=str(cfg.get(\"server_url\", \"\") or \"\").strip(),\n+                home_path=home_path,\n+            )\n+        except RuntimeError as exc:\n+            result.error = str(exc)\n+            result.error_kind = _classify_bws_error(str(exc))\n+            return result\n+\n+        result.secrets = secrets\n+        result.warnings.extend(warnings)\n+        return result\n+\n+\n+def _classify_bws_error(message: str) -> ErrorKind:\n+    \"\"\"Best-effort mapping of bws failure text onto the shared taxonomy.\"\"\"\n+    lowered = message.lower()\n+    if \"timed out\" in lowered:\n+        return ErrorKind.TIMEOUT\n+    if \"binary not available\" in lowered or \"failed to invoke\" in lowered:\n+        return ErrorKind.BINARY_MISSING\n+    if any(tok in lowered for tok in (\"unauthorized\", \"invalid token\",\n+                                      \"access token\", \"401\", \"403\")):\n+        return ErrorKind.AUTH_FAILED\n+    if any(tok in lowered for tok in (\"network\", \"connection\", \"resolve\",\n+                                      \"download\", \"dns\")):\n+        return ErrorKind.NETWORK\n+    return ErrorKind.INTERNAL\n+\n+\n # ---------------------------------------------------------------------------\n # Test hook — used by hermetic tests to flush the cache between cases.\n # ---------------------------------------------------------------------------","@@ -686,7 +726,4 @@ def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:\n     writer itself.\n     \"\"\"\n     _CACHE.clear()\n-    try:\n-        _disk_cache_path(home_path).unlink()\n-    except (FileNotFoundError, OSError):\n-        pass\n+    _DISK_CACHE.clear(home_path)"]},{"file":"agent/secret_sources/onepassword.py","hunks":["@@ -0,0 +1,643 @@\n+\"\"\"1Password (`op` CLI) secret source.\n+\n+Resolve provider credentials from 1Password ``op://vault/item/field``\n+references at process startup so they don't have to live in plaintext in\n+``~/.hermes/.env``.\n+\n+Design summary\n+--------------\n+\n+* Users map environment-variable names to official 1Password secret\n+  references in ``secrets.onepassword.env``::\n+\n+      secrets:\n+        onepassword:\n+          enabled: true\n+          env:\n+            OPENAI_API_KEY: \"[redacted:secret-assignment] key\"\n+            ANTHROPIC_API_KEY: \"[redacted:secret-assignment]\"\n+\n+* After ``.env`` loads, each reference is resolved with a single\n+  ``op read -- <reference>`` call and injected into ``os.environ`` (the\n+  same point in startup as the Bitwarden source).\n+* Authentication is whatever the user's ``op`` CLI already uses — a\n+  service-account token (``OP_SERVICE_ACCOUNT_TOKEN``) for headless boxes,\n+  or a desktop/interactive session (``OP_SESSION_*``).  Hermes never\n+  authenticates on the user's behalf; it shells out to an already-trusted,\n+  already-authenticated CLI.\n+* Failures NEVER block startup.  A missing ``op`` binary, expired auth, a\n+  bad reference, or a permission error each surface a one-line warning and\n+  Hermes continues with whatever credentials ``.env`` already had.\n+\n+The atomic-write / ``0600`` / TTL cache mechanics are shared with the other\n+backends via :mod:`agent.secret_sources._cache` — successful, complete pulls\n+are cached in-process and on disk under ``<hermes_home>/cache/op_cache.json``\n+so back-to-back short-lived ``hermes`` invocations don't re-shell ``op`` for\n+every reference.  The disk file holds only resolved secret *values*; auth\n+material is fingerprinted, never stored.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import hashlib\n+import logging\n+import os\n+import re\n+import shutil\n+import subprocess\n+import time\n+from pathlib import Path\n+from typing import Dict, List, Optional, Tuple\n+\n+from agent.secret_sources._cache import (\n+    CachedFetch,\n+    DiskCache,\n+    FetchResult,\n+    is_valid_env_name,\n+)\n+from agent.secret_sources.base import ErrorKind, SecretSource\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Configuration constants\n+# ---------------------------------------------------------------------------\n+\n+# How long to wait for a single `op read`, in seconds.\n+_OP_RUN_TIMEOUT = 30\n+\n+# Default env var the official `op` CLI reads for service-account auth.  Users\n+# can point `service_account_token_env` at a different name; we always export\n+# the value to the child as OP_SERVICE_ACCOUNT_TOKEN, which is what `op` itself\n+# looks for.\n+_DEFAULT_TOKEN_ENV = \"[redacted:secret-assignment]\"\n+\n+# Strip whole ANSI CSI sequences (colour, cursor moves, line erases) from any\n+# `op` diagnostic we surface — not just the lone ESC byte — so a control\n+# sequence can't reposition the cursor or hide text after a redaction marker.\n+_ANSI_CSI_RE = re.compile(r\"\\x1b\\[[0-?]*[ -/]*[@-~]\")\n+\n+# Env vars the `op` child actually needs.  We build a minimal allowlisted env\n+# rather than copying all of os.environ (which, post-dotenv, holds every\n+# provider credential) into the child — tighter blast radius if `op` or\n+# anything it execs ever misbehaves.  OP_SESSION_* and the token are added\n+# dynamically in _op_child_env().\n+_OP_ENV_ALLOWLIST = (\n+    \"PATH\",\n+    \"HOME\",\n+    \"USERPROFILE\",\n+    \"APPDATA\",\n+    \"LOCALAPPDATA\",\n+    \"SystemRoot\",\n+    \"TMPDIR\",\n+    \"TMP\",\n+    \"TEMP\",\n+    \"XDG_CONFIG_HOME\",\n+    \"XDG_RUNTIME_DIR\",\n+    \"OP_ACCOUNT\",\n+    \"OP_CONNECT_HOST\",\n+    \"OP_CONNECT_TOKEN\",\n+)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Cache\n+# ---------------------------------------------------------------------------\n+\n+# In-process cache.  The key folds in str(home_path) so a HERMES_HOME switch\n+# inside one long-lived process (e.g. the gateway) can't return another\n+# profile's secrets from L1.  The disk layer omits home from its serialized\n+# key because the file already lives under the home dir (see _disk_key_str).\n+_CacheKey = Tuple[str, str, str, str]  # (auth_fp, account, home, refs_fp)\n+_CACHE: Dict[_CacheKey, CachedFetch] = {}\n+\n+_DISK_CACHE_BASENAME = \"op_cache.json\"\n+\n+\n+def _disk_key_str(cache_key: _CacheKey) -> str:\n+    \"\"\"Serialize a cache key for on-disk storage, omitting home_path.\n+\n+    The disk file is already partitioned by home (it lives under\n+    ``<home>/cache/``), so the path provides the home dimension; folding it\n+    into the key string too would be redundant.\n+    \"\"\"\n+    auth_fp, account, _home, refs_fp = cache_key\n+    return f\"{auth_fp}|{account}|{refs_fp}\"\n+\n+\n+_DISK_CACHE: DiskCache = DiskCache(\n+    _DISK_CACHE_BASENAME, key_serializer=_disk_key_str\n+)\n+\n+\n+def _disk_cache_path(home_path: Optional[Path] = None) -> Path:\n+    \"\"\"Path to the on-disk cache (exposed for tests and direct callers).\"\"\"\n+    return _DISK_CACHE.path(home_path)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Reference validation + fingerprinting\n+# ---------------------------------------------------------------------------\n+\n+\n+def _validate_references(\n+    references: Optional[Dict[str, str]],\n+) -> Tuple[Dict[str, str], List[str]]:\n+    \"\"\"Return ``(valid_refs, warnings)`` from an ``env`` mapping.\n+\n+    A reference is kept only if its target env-var name is a valid POSIX\n+    name and the value is a stripped ``op://…`` reference string.  Everything\n+    else produces a warning and is dropped (never fatal).\n+    \"\"\"\n+    valid: Dict[str, str] = {}\n+    warnings: List[str] = []\n+    for name, ref in (references or {}).items():\n+        if not is_valid_env_name(name):\n+            warnings.append(f\"Skipping {name!r}: not a valid env-var name\")\n+            continue\n+        if not isinstance(ref, str):\n+            warnings.append(f\"Skipping {name!r}: reference is not a string\")\n+            continue\n+        cleaned = ref.strip()\n+        if not cleaned.startswith(\"op://\"):\n+            warnings.append(\n+                f\"Skipping {name!r}: {ref!r} is not an op:// secret reference\"\n+            )\n+            continue\n+        valid[name] = cleaned\n+    return valid, warnings\n+\n+\n+def _auth_fingerprint(token_env: str) -> str:\n+    \"\"\"SHA-256 prefix over the auth material `op` would use.\n+\n+    Folds in the service-account token, ``OP_ACCOUNT``, and *all*\n+    ``OP_SESSION_*`` vars (the names `op` actually exports for interactive\n+    sessions — ``OP_SESSION_<account_shorthand>``).  Signing out and into a\n+    different identity therefore changes the cache key, so a value cached under\n+    a previous identity is never served under a new one.  Never logged or\n+    displayed; the raw token never leaves this hash.\n+    \"\"\"\n+    parts: List[str] = [\n+        f\"token=[redacted:secret-assignment] '')}\",\n+        f\"account={os.environ.get('OP_ACCOUNT', '')}\",\n+    ]\n+    for key in sorted(os.environ):\n+        if key.startswith(\"OP_SESSION_\"):\n+            parts.append(f\"{key}={os.environ[key]}\")\n+    material = \"\\n\".join(parts)\n+    return hashlib.sha256(material.encode(\"utf-8\")).hexdigest()[:16]\n+\n+\n+def _refs_fingerprint(references: Dict[str, str]) -> str:\n+    \"\"\"SHA-256 prefix over the configured name→reference mapping.\"\"\"\n+    material = \"\\n\".join(f\"{name}={references[name]}\" for name in sorted(references))\n+    return hashlib.sha256(material.encode(\"utf-8\")).hexdigest()[:16]\n+\n+\n+# ---------------------------------------------------------------------------\n+# Binary discovery\n+# ---------------------------------------------------------------------------\n+\n+\n+def find_op(binary_path: str = \"\") -> Optional[Path]:\n+    \"\"\"Resolve a usable ``op`` binary, or None.\n+\n+    When ``binary_path`` is set it is used verbatim and PATH is NOT consulted\n+    — pinning an absolute path is a way to avoid trusting whatever ``op`` shows\n+    up first on ``PATH``.  A pinned-but-missing path returns None (the caller\n+    surfaces a clear error) rather than silently falling back.\n+    \"\"\"\n+    if binary_path:\n+        pinned = Path(binary_path)\n+        if pinned.exists() and os.access(pinned, os.X_OK):\n+            return pinned\n+        return None\n+    found = shutil.which(\"op\")\n+    return Path(found) if found else None\n+\n+\n+# ---------------------------------------------------------------------------\n+# `op read` invocation\n+# ---------------------------------------------------------------------------\n+\n+\n+def _scrub(text: str) -> str:\n+    \"\"\"Remove ANSI control sequences and trim, for safe message surfacing.\"\"\"\n+    return _ANSI_CSI_RE.sub(\"\", text).replace(\"\\x1b\", \"\").strip()\n+\n+\n+def _op_child_env(token_value: str) -> Dict[str, str]:\n+    \"\"\"Build a minimal allowlisted environment for the ``op`` child process.\"\"\"\n+    env: Dict[str, str] = {}\n+    for key in _OP_ENV_ALLOWLIST:\n+        val = os.environ.get(key)\n+        if val is not None:\n+            env[key] = val\n+    # Desktop / interactive session credentials.\n+    for key, val in os.environ.items():\n+        if key.startswith(\"OP_SESSION_\"):\n+            env[key] = val\n+    # `op` reads OP_SERVICE_ACCOUNT_TOKEN regardless of which env var the user\n+    # configured Hermes to source it from, so normalize to that name here.\n+    if token_value:\n+        env[\"OP_SERVICE_ACCOUNT_TOKEN\"] = token_value\n+    env[\"NO_COLOR\"] = \"1\"\n+    return env\n+\n+\n+def _run_op_read(\n+    op: Path,\n+    reference: str,\n+    *,\n+    account: str = \"\",\n+    token_value: str = \"\",\n+) -> str:\n+    \"\"\"Resolve a single ``op://`` reference to its value.\n+\n+    Raises :class:`RuntimeError` on any failure — including a ``returncode 0``\n+    with empty output, which would otherwise silently clobber a good\n+    ``.env``/shell credential with ``\"\"``.\n+    \"\"\"\n+    cmd: List[str] = [str(op), \"read\"]\n+    if account:\n+        cmd += [\"--account\", account]\n+    # `--` terminates option parsing so a reference can never be mis-parsed as\n+    # an `op` flag even if validation is ever loosened.\n+    cmd += [\"--\", reference]\n+\n+    try:\n+        proc = subprocess.run(  # noqa: S603 — op path is user-trusted, argv list\n+            cmd,\n+            env=_op_child_env(token_value),\n+            capture_output=True,\n+            text=True,\n+            encoding=\"utf-8\",\n+            errors=\"replace\",\n+            timeout=_OP_RUN_TIMEOUT,\n+        )\n+    except subprocess.TimeoutExpired as exc:\n+        raise RuntimeError(\n+            f\"op read timed out after {_OP_RUN_TIMEOUT}s for {reference!r}\"\n+        ) from exc\n+    except OSError as exc:\n+        raise RuntimeError(f\"failed to invoke op: {exc}\") from exc\n+\n+    if proc.returncode != 0:\n+        err = _scrub(proc.stderr or \"\")[:200]\n+        if err:\n+            raise RuntimeError(f\"op read failed for {reference!r}: {err}\")\n+        raise RuntimeError(\n+            f\"op read exited {proc.returncode} for {reference!r}\"\n+        )\n+\n+    # `op` appends a trailing newline; strip only that so a value with\n+    # intentional internal/edge spaces survives.  But a value that is empty or\n+    # whitespace-only is treated as empty: applying it would silently clobber a\n+    # good .env/shell credential with effectively nothing.\n+    value = (proc.stdout or \"\").rstrip(\"\\r\\n\")\n+    if not value.strip():\n+        raise RuntimeError(f\"op read returned an empty value for {reference!r}\")\n+    return value\n+\n+\n+# ---------------------------------------------------------------------------\n+# Fetch\n+# ---------------------------------------------------------------------------\n+\n+\n+def fetch_onepassword_secrets(\n+    *,\n+    references: Dict[str, str],\n+    account: str = \"\",\n+    token_env: str = _DEFAULT_TOKEN_ENV,\n+    binary: Optional[Path] = None,\n+    binary_path: str = \"\",\n+    use_cache: bool = True,\n+    cache_ttl_seconds: float = 300,\n+    home_path: Optional[Path] = None,\n+) -> Tuple[Dict[str, str], List[str]]:\n+    \"\"\"Resolve ``references`` (name → ``op://…``) to ``(secrets, warnings)``.\n+\n+    Raises :class:`RuntimeError` only when no ``op`` binary is available — a\n+    fatal \"can't fetch anything\" condition.  Per-reference failures (expired\n+    auth, bad reference, empty value) are collected as warnings and the\n+    reference is dropped, so one bad entry never sinks the rest.\n+\n+    Only a complete, error-free pull is cached, so a transient auth failure\n+    isn't frozen in for the whole TTL window.\n+    \"\"\"\n+    valid, warnings = _validate_references(references)\n+    if not valid:\n+        return {}, warnings\n+\n+    token_value = [redacted:secret-assignment] \"\").strip()\n+    cache_key: _CacheKey = (\n+        _auth_fingerprint(token_env),\n+        account or \"\",\n+        str(home_path) if home_path is not None else \"\",\n+        _refs_fingerprint(valid),\n+    )\n+\n+    if use_cache:\n+        cached = _CACHE.get(cache_key)\n+        if cached and cached.is_fresh(cache_ttl_seconds):\n+            return dict(cached.secrets), warnings\n+        disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)\n+        if disk_cached is not None:\n+            # Promote into L1 so later fetches in this process skip the disk read.\n+            _CACHE[cache_key] = disk_cached\n+            return dict(disk_cached.secrets), warnings\n+\n+    op = binary or find_op(binary_path)\n+    if op is None:\n+        raise RuntimeError(\n+            \"op CLI not found.  Install the 1Password CLI \"\n+            \"(https://developer.1password.com/docs/cli/get-started/) or set \"\n+            \"secrets.onepassword.binary_path to its absolute location.\"\n+        )\n+\n+    secrets: [redacted:secret-assignment] str] = {}\n+    read_errors = 0\n+    for name in sorted(valid):\n+        try:\n+            secrets[name] = _run_op_read(\n+                op, valid[name], account=account, token_value=[redacted:secret-assignment]\n+            )\n+        except RuntimeError as exc:\n+            warnings.append(str(exc))\n+            read_errors += 1\n+\n+    if use_cache and not read_errors and secrets:\n+        entry = CachedFetch(secrets=[redacted:secret-assignment] fetched_at=time.time())\n+        _CACHE[cache_key] = entry\n+        _DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)\n+\n+    return secrets, warnings\n+\n+\n+# ---------------------------------------------------------------------------\n+# Public entry point — called from hermes_cli.env_loader\n+# ---------------------------------------------------------------------------\n+\n+\n+def apply_onepassword_secrets(\n+    *,\n+    enabled: bool,\n+    env: Optional[Dict[str, str]] = None,\n+    account: str = \"\",\n+    service_account_token_env: str = _DEFAULT_TOKEN_ENV,\n+    binary_path: str = \"\",\n+    override_existing: bool = True,\n+    cache_ttl_seconds: float = 300,\n+    home_path: Optional[Path] = None,\n+) -> FetchResult:\n+    \"\"\"Resolve configured ``op://`` references and set them on ``os.environ``.\n+\n+    Called by ``load_hermes_dotenv()`` after the .env files have loaded.\n+    Intentionally defensive — any failure returns a :class:`FetchResult` with\n+    ``error`` set (or surfaces warnings); it never raises.\n+\n+    Parameters mirror the ``secrets.onepassword.*`` config keys so the caller\n+    can splat the dict in.  References that are already satisfied by the\n+    current environment (when ``override_existing`` is false) are skipped\n+    *before* fetching, so ``op`` is never invoked for a value that would be\n+    discarded.\n+    \"\"\"\n+    result = FetchResult()\n+\n+    if not enabled:\n+        return result\n+\n+    valid, warnings = _validate_references(env)\n+    result.warnings.extend(warnings)\n+\n+    # Skip-before-fetch: never resolve a reference we'd only throw away.\n+    refs_to_fetch: Dict[str, str] = {}\n+    for name, ref in valid.items():\n+        if name == service_account_token_env:\n+            # Never let a resolved secret clobber the very token used to auth.\n+            result.skipped.append(name)\n+            continue\n+        if not override_existing and os.environ.get(name):\n+            result.skipped.append(name)\n+            continue\n+        refs_to_fetch[name] = ref\n+\n+    if not refs_to_fetch:\n+        return result\n+\n+    binary = find_op(binary_path)\n+    result.binary_path = binary\n+    if binary is None:\n+        if binary_path:\n+            result.error = (\n+                f\"secrets.onepassword.binary_path ({binary_path!r}) is not an \"\n+                \"executable op binary.\"\n+            )\n+        else:\n+            result.error = (\n+                \"secrets.onepassword.enabled is true but the op CLI was not \"\n+                \"found on PATH.  Install it \"\n+                \"(https://developer.1password.com/docs/cli/get-started/) or set \"\n+                \"secrets.onepassword.binary_path.\"\n+            )\n+        return result\n+\n+    try:\n+        secrets, fetch_warnings = fetch_onepassword_secrets(\n+            references=refs_to_fetch,\n+            account=account,\n+            token_env=[redacted:secret-assignment]\n+            binary=binary,\n+            cache_ttl_seconds=cache_ttl_seconds,\n+            home_path=home_path,\n+        )\n+    except RuntimeError as exc:\n+        result.error = str(exc)\n+        return result\n+\n+    result.secrets = secrets\n+    result.warnings.extend(fetch_warnings)\n+\n+    for name, value in secrets.items():\n+        # The token-var and override guards already filtered refs_to_fetch, but\n+        # re-check defensively in case the fetch layer ever returns extras.\n+        if name == service_account_token_env:\n+            if name not in result.skipped:\n+                result.skipped.append(name)\n+            continue\n+        if not override_existing and os.environ.get(name):\n+            if name not in result.skipped:\n+                result.skipped.append(name)\n+            continue\n+        os.environ[name] = value\n+        result.applied.append(name)\n+\n+    return result\n+\n+\n+# ---------------------------------------------------------------------------\n+# SecretSource adapter — the registry-facing wrapper around this module.\n+# ---------------------------------------------------------------------------\n+\n+\n+class OnePasswordSource(SecretSource):\n+    \"\"\"1Password as a registered secret source.\n+\n+    Thin adapter over the module's fetch machinery.  ``fetch()`` only\n+    *fetches* — precedence, override semantics, conflict warnings, and\n+    the ``os.environ`` writes are the orchestrator's job\n+    (see ``agent.secret_sources.registry.apply_all``).\n+\n+    1Password is a **mapped** source: the user explicitly binds each env\n+    var to an ``op://`` reference under ``secrets.onepassword.env``, so\n+    its claims outrank bulk sources (e.g. a Bitwarden project dump) on\n+    contested vars.\n+    \"\"\"\n+\n+    name = \"onepassword\"\n+    label = \"1Password\"\n+    shape = \"mapped\"\n+    scheme = \"op\"\n+\n+    def override_existing(self, cfg: dict) -> bool:\n+        # Default True: an explicit VAR→op:// binding is the strongest\n+        # user intent there is — leaving a stale .env line in place\n+        # should not silently defeat it (same rotation rationale as\n+        # Bitwarden).\n+        return bool(isinstance(cfg, dict) and cfg.get(\"override_existing\", True))\n+\n+    def protected_env_vars(self, cfg: dict):\n+        token_env = [redacted:secret-assignment]\n+        if isinstance(cfg, dict):\n+            token_env = [redacted:secret-assignment]\"service_account_token_env\") or token_env)\n+        return frozenset({token_env})\n+\n+    def config_schema(self) -> dict:\n+        return {\n+            \"enabled\": {\"description\": \"Master switch\", \"default\": False},\n+            \"env\": {\n+                \"description\": \"Map of ENV_VAR -> op://vault/item/field reference\",\n+                \"default\": {},\n+            },\n+            \"account\": {\n+                \"description\": \"op --account shorthand (empty = default account)\",\n+                \"default\": \"\",\n+            },\n+            \"service_account_token_env\": {\n+                \"description\": \"Env var holding the service-account token \"\n+                               \"(unset = desktop/interactive session)\",\n+                \"default\": _DEFAULT_TOKEN_ENV,\n+            },\n+            \"binary_path\": {\n+                \"description\": \"Pin the op binary (empty = resolve via PATH)\",\n+                \"default\": \"\",\n+            },\n+            \"cache_ttl_seconds\": {\n+                \"description\": \"Disk+memory cache TTL; 0 disables\",\n+                \"default\": 300,\n+            },\n+            \"override_existing\": {\n+                \"description\": \"Resolved values overwrite .env/shell values\",\n+                \"default\": True,\n+            },\n+        }\n+\n+    def fetch(self, cfg: dict, home_path: Path) -> FetchResult:\n+        cfg = cfg if isinstance(cfg, dict) else {}\n+        result = FetchResult()\n+\n+        env_map = cfg.get(\"env\")\n+        valid, warnings = _validate_references(\n+            env_map if isinstance(env_map, dict) else None\n+        )\n+        result.warnings.extend(warnings)\n+        if not valid:\n+            if not warnings:\n+                result.error = (\n+                    \"secrets.onepassword.enabled is true but the env: map is \"\n+                    \"empty.  Add ENV_VAR: op://vault/item/field entries.\"\n+                )\n+                result.error_kind = ErrorKind.NOT_CONFIGURED\n+            return result\n+\n+        binary_path = str(cfg.get(\"binary_path\") or \"\")\n+        binary = find_op(binary_path)\n+        result.binary_path = binary\n+        if binary is None:\n+            if binary_path:\n+                result.error = (\n+                    f\"secrets.onepassword.binary_path ({binary_path!r}) is \"\n+                    \"not an executable op binary.\"\n+                )\n+            else:\n+                result.error = (\n+                    \"secrets.onepassword.enabled is true but the op CLI was \"\n+                    \"not found on PATH.  Install it \"\n+                    \"(https://developer.1password.com/docs/cli/get-started/) \"\n+                    \"or set secrets.onepassword.binary_path.\"\n+                )\n+            result.error_kind = ErrorKind.BINARY_MISSING\n+            return result\n+\n+        try:\n+            ttl = float(cfg.get(\"cache_ttl_seconds\", 300))\n+        except (TypeError, ValueError):\n+            ttl = 300.0\n+\n+        try:\n+            secrets, fetch_warnings = fetch_onepassword_secrets(\n+                references=valid,\n+                account=str(cfg.get(\"account\") or \"\"),\n+                token_env=str(\n+                    cfg.get(\"service_account_token_env\") or _DEFAULT_TOKEN_ENV\n+                ),\n+                binary=binary,\n+                cache_ttl_seconds=ttl,\n+                home_path=home_path,\n+            )\n+        except RuntimeError as exc:\n+            result.error = str(exc)\n+            result.error_kind = _classify_op_error(str(exc))\n+            return result\n+\n+        result.secrets = secrets\n+        result.warnings.extend(fetch_warnings)\n+        return result\n+\n+\n+def _classify_op_error(message: str) -> ErrorKind:\n+    \"\"\"Best-effort mapping of op failure text onto the shared taxonomy.\"\"\"\n+    lowered = message.lower()\n+    if \"timed out\" in lowered:\n+        return ErrorKind.TIMEOUT\n+    if \"not found on path\" in lowered or \"not an executable\" in lowered \\\n+            or \"failed to invoke\" in lowered:\n+        return ErrorKind.BINARY_MISSING\n+    if any(tok in lowered for tok in (\"unauthorized\", \"not signed in\",\n+                                      \"session expired\", \"authentication\",\n+                                      \"401\", \"403\")):\n+        return ErrorKind.AUTH_FAILED\n+    if \"empty value\" in lowered:\n+        return ErrorKind.EMPTY_VALUE\n+    if any(tok in lowered for tok in (\"network\", \"connection\", \"resolve host\",\n+                                      \"dns\")):\n+        return ErrorKind.NETWORK\n+    return ErrorKind.INTERNAL\n+\n+\n+# ---------------------------------------------------------------------------\n+# Test hook — used by hermetic tests to flush the cache between cases.\n+# ---------------------------------------------------------------------------\n+\n+\n+def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:\n+    \"\"\"Clear in-process AND disk caches.\n+\n+    Tests can pass ``home_path`` to scope the disk cleanup to a tmpdir.\n+    Without it we fall back to the same default resolution as the writer.\n+    \"\"\"\n+    _CACHE.clear()\n+    _DISK_CACHE.clear(home_path)"]},{"file":"agent/secret_sources/registry.py","hunks":["@@ -0,0 +1,370 @@\n+\"\"\"Secret-source registry + apply orchestrator.\n+\n+This module owns everything that must be uniform across secret backends\n+so no individual source can get it wrong:\n+\n+* registration (name/scheme uniqueness, API-version gating)\n+* per-source wall-clock timeout enforcement around ``fetch()``\n+* precedence: mapped sources beat bulk sources; within a shape,\n+  ``secrets.sources`` order (or registration order) decides; first\n+  claim wins — later sources never silently clobber an earlier one\n+* ``override_existing`` semantics (may beat .env/shell, never another\n+  secret source, never a protected var)\n+* cross-source conflict warnings (shadowed claims are always surfaced)\n+* provenance: which source supplied every applied var\n+\n+The single entry point for startup is :func:`apply_all`, called from\n+``hermes_cli.env_loader._apply_external_secret_sources()``.\n+\n+Plugins register additional sources via\n+``PluginContext.register_secret_source()`` which lands in\n+:func:`register_source`.  In-tree sources are registered lazily by\n+:func:`_ensure_builtin_sources` — the set of bundled sources is\n+deliberately closed (Bitwarden, and 1Password once it lands); new\n+third-party backends ship as standalone plugin repos implementing\n+:class:`agent.secret_sources.base.SecretSource`.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import concurrent.futures\n+import logging\n+from dataclasses import dataclass, field\n+from pathlib import Path\n+from typing import Dict, List, Optional\n+\n+from agent.secret_sources.base import (\n+    SECRET_SOURCE_API_VERSION,\n+    ErrorKind,\n+    FetchResult,\n+    SecretSource,\n+    is_valid_env_name,\n+)\n+\n+logger = logging.getLogger(__name__)\n+\n+# Ordered registry: name → source instance.  Python dicts preserve\n+# insertion order, which doubles as the default apply order.\n+_SOURCES: Dict[str, SecretSource] = {}\n+_BUILTINS_LOADED = False\n+\n+\n+@dataclass\n+class AppliedVar:\n+    \"\"\"Provenance record for one env var the orchestrator set.\"\"\"\n+\n+    name: str\n+    source: str          # SecretSource.name\n+    shape: str           # \"mapped\" | \"bulk\"\n+    overrode_env: bool   # replaced a pre-existing .env/shell value\n+\n+\n+@dataclass\n+class SourceReport:\n+    \"\"\"One source's outcome within an :class:`ApplyReport`.\"\"\"\n+\n+    name: str\n+    label: str\n+    result: FetchResult\n+    applied: List[str] = field(default_factory=list)\n+    skipped_existing: List[str] = field(default_factory=list)   # .env/shell won\n+    skipped_claimed: List[str] = field(default_factory=list)    # earlier source won\n+    skipped_protected: List[str] = field(default_factory=list)  # bootstrap-auth guard\n+    skipped_invalid: List[str] = field(default_factory=list)    # bad env-var name\n+\n+\n+@dataclass\n+class ApplyReport:\n+    \"\"\"Merged outcome of one orchestrated apply pass.\"\"\"\n+\n+    sources: List[SourceReport] = field(default_factory=list)\n+    provenance: Dict[str, AppliedVar] = field(default_factory=dict)\n+    conflicts: List[str] = field(default_factory=list)  # human-readable warnings\n+\n+    @property\n+    def applied_any(self) -> bool:\n+        return bool(self.provenance)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Registration\n+# ---------------------------------------------------------------------------\n+\n+\n+def register_source(source: SecretSource, *, replace: bool = False) -> bool:\n+    \"\"\"Register a secret source.  Returns True on success.\n+\n+    Rejections are logged, never raised — a bad plugin must not take\n+    down startup.  ``replace`` allows tests / user plugins to override\n+    a bundled source of the same name (last-writer-wins like model\n+    providers), but scheme collisions across *different* names are\n+    always rejected.\n+    \"\"\"\n+    if not isinstance(source, SecretSource):\n+        logger.warning(\n+            \"Ignoring secret source %r: does not inherit from SecretSource\",\n+            source,\n+        )\n+        return False\n+    name = getattr(source, \"name\", \"\") or \"\"\n+    if not name or not name.replace(\"_\", \"\").isalnum() or name != name.lower():\n+        logger.warning(\"Ignoring secret source with invalid name %r\", name)\n+        return False\n+    if getattr(source, \"api_version\", None) != SECRET_SOURCE_API_VERSION:\n+        logger.warning(\n+            \"Ignoring secret source '%s': built against secret-source API v%s, \"\n+            \"this Hermes speaks v%s\",\n+            name, getattr(source, \"api_version\", \"?\"), SECRET_SOURCE_API_VERSION,\n+        )\n+        return False\n+    if getattr(source, \"shape\", None) not in (\"mapped\", \"bulk\"):\n+        logger.warning(\n+            \"Ignoring secret source '%s': shape must be 'mapped' or 'bulk', got %r\",\n+            name, getattr(source, \"shape\", None),\n+        )\n+        return False\n+    if name in _SOURCES and not replace:\n+        logger.warning(\"Secret source '%s' already registered; ignoring duplicate\", name)\n+        return False\n+    scheme = getattr(source, \"scheme\", None)\n+    if scheme:\n+        for other_name, other in _SOURCES.items():\n+            if other_name != name and getattr(other, \"scheme\", None) == scheme:\n+                logger.warning(\n+                    \"Ignoring secret source '%s': scheme '%s://' is already \"\n+                    \"owned by source '%s'\",\n+                    name, scheme, other_name,\n+                )\n+                return False\n+    _SOURCES[name] = source\n+    return True\n+\n+\n+def get_source(name: str) -> Optional[SecretSource]:\n+    _ensure_builtin_sources()\n+    return _SOURCES.get(name)\n+\n+\n+def list_sources() -> List[SecretSource]:\n+    _ensure_builtin_sources()\n+    return list(_SOURCES.values())\n+\n+\n+def _ensure_builtin_sources() -> None:\n+    \"\"\"Idempotently register the bundled sources.\n+\n+    Lazy so importing this module stays cheap and so a broken bundled\n+    source can never break registration of the others.\n+    \"\"\"\n+    global _BUILTINS_LOADED\n+    if _BUILTINS_LOADED:\n+        return\n+    _BUILTINS_LOADED = True\n+    try:\n+        from agent.secret_sources.bitwarden import BitwardenSource\n+\n+        register_source(BitwardenSource())\n+    except Exception:  # noqa: BLE001 — never block startup\n+        logger.warning(\"Failed to register bundled Bitwarden secret source\",\n+                       exc_info=True)\n+    try:\n+        from agent.secret_sources.onepassword import OnePasswordSource\n+\n+        register_source(OnePasswordSource())\n+    except Exception:  # noqa: BLE001 — never block startup\n+        logger.warning(\"Failed to register bundled 1Password secret source\",\n+                       exc_info=True)\n+\n+\n+def _reset_registry_for_tests() -> None:\n+    global _BUILTINS_LOADED\n+    _SOURCES.clear()\n+    _BUILTINS_LOADED = False\n+\n+\n+# ---------------------------------------------------------------------------\n+# Orchestrated apply\n+# ---------------------------------------------------------------------------\n+\n+\n+def _fetch_with_timeout(\n+    source: SecretSource, cfg: dict, home_path: Path\n+) -> FetchResult:\n+    \"\"\"Run source.fetch() under a wall-clock budget; never raises.\n+\n+    The budget is enforced with a daemon worker thread: a source that\n+    blows its budget is reported as ``TIMEOUT`` and its (eventual)\n+    result is discarded.  The thread itself may linger until process\n+    exit — acceptable for a startup-only path, and strictly better than\n+    an unbounded hang on every ``hermes`` invocation.\n+    \"\"\"\n+    timeout = source.fetch_timeout_seconds(cfg)\n+    executor = concurrent.futures.ThreadPoolExecutor(\n+        max_workers=1, thread_name_prefix=f\"secret-src-{source.name}\"\n+    )\n+    try:\n+        future = executor.submit(source.fetch, cfg, home_path)\n+        try:\n+            result = future.result(timeout=timeout)\n+        except concurrent.futures.TimeoutError:\n+            future.cancel()\n+            res = FetchResult()\n+            res.error = (\n+                f\"fetch exceeded {timeout:.0f}s budget — startup continued \"\n+                \"without this source (raise secrets.\"\n+                f\"{source.name}.timeout_seconds if the backend is just slow)\"\n+            )\n+            res.error_kind = ErrorKind.TIMEOUT\n+            return res\n+        except Exception as exc:  # noqa: BLE001 — contract violation, contain it\n+            res = FetchResult()\n+            res.error = f\"fetch raised {type(exc).__name__}: {exc}\"\n+            res.error_kind = ErrorKind.INTERNAL\n+            return res\n+    finally:\n+        executor.shutdown(wait=False)\n+\n+    if not isinstance(result, FetchResult):\n+        res = FetchResult()\n+        res.error = (\n+            f\"fetch returned {type(result).__name__} instead of FetchResult\"\n+        )\n+        res.error_kind = ErrorKind.INTERNAL\n+        return res\n+    return result\n+\n+\n+def _ordered_enabled_sources(secrets_cfg: dict) -> List[SecretSource]:\n+    \"\"\"Resolve which sources run, in which order.\n+\n+    Order: the optional ``secrets.sources`` list wins; sources not named\n+    there follow in registration order.  Enabled = the source's own\n+    ``is_enabled`` says so for its config section.  Mapped-vs-bulk\n+    precedence is applied on top of this order by :func:`apply_all`.\n+    \"\"\"\n+    _ensure_builtin_sources()\n+\n+    explicit = secrets_cfg.get(\"sources\")\n+    order: List[str] = []\n+    if isinstance(explicit, list):\n+        for entry in explicit:\n+            if isinstance(entry, str) and entry in _SOURCES and entry not in order:\n+                order.append(entry)\n+        unknown = [e for e in explicit\n+                   if isinstance(e, str) and e not in _SOURCES]\n+        if unknown:\n+            logger.warning(\n+                \"secrets.sources names unknown source(s): %s (known: %s)\",\n+                \", \".join(unknown), \", \".join(_SOURCES) or \"none\",\n+            )\n+    for name in _SOURCES:\n+        if name not in order:\n+            order.append(name)\n+\n+    enabled: List[SecretSource] = []\n+    for name in order:\n+        source = _SOURCES[name]\n+        cfg = secrets_cfg.get(name)\n+        cfg = cfg if isinstance(cfg, dict) else {}\n+        try:\n+            if source.is_enabled(cfg):\n+                enabled.append(source)\n+        except Exception:  # noqa: BLE001\n+            logger.warning(\"Secret source '%s' is_enabled() raised; skipping\",\n+                           name, exc_info=True)\n+    return enabled\n+\n+\n+def apply_all(secrets_cfg: dict, home_path: Path,\n+              environ: Optional[Dict[str, str]] = None) -> ApplyReport:\n+    \"\"\"Fetch from every enabled source and apply the merged result to env.\n+\n+    ``environ`` defaults to ``os.environ``; injectable for tests.\n+\n+    Precedence per env var (most-specific intent wins):\n+\n+    1. Pre-existing env (.env / shell) — unless the winning source has\n+       ``override_existing: true``.\n+    2. Mapped sources, in configured order.\n+    3. Bulk sources, in configured order.\n+\n+    First claim wins.  A later source that also carries the var gets a\n+    ``skipped_claimed`` entry and a conflict warning — never a silent\n+    clobber, and ``override_existing`` never applies across sources.\n+    \"\"\"\n+    import os as _os\n+\n+    env = environ if environ is not None else _os.environ\n+    report = ApplyReport()\n+\n+    secrets_cfg = [redacted:secret-assignment] if isinstance(secrets_cfg, dict) else {}\n+    enabled = _ordered_enabled_sources(secrets_cfg)\n+    if not enabled:\n+        return report\n+\n+    # Mapped sources outrank bulk sources regardless of list order:\n+    # an explicit VAR→ref binding is stronger intent than a project dump.\n+    ordered = ([s for s in enabled if s.shape == \"mapped\"]\n+               + [s for s in enabled if s.shape == \"bulk\"])\n+\n+    # Fetch phase.\n+    fetches: List[tuple[SecretSource, dict, FetchResult]] = []\n+    protected: Dict[str, str] = {}  # var → source that protects it\n+    for source in ordered:\n+        cfg = secrets_cfg.get(source.name)\n+        cfg = cfg if isinstance(cfg, dict) else {}\n+        result = _fetch_with_timeout(source, cfg, home_path)\n+        fetches.append((source, cfg, result))\n+        try:\n+            for var in source.protected_env_vars(cfg):\n+                protected.setdefault(var, source.name)\n+        except Exception:  # noqa: BLE001\n+            pass\n+\n+    # Apply phase — sequential, first-wins, fully attributed.\n+    claimed: Dict[str, str] = {}  # var → source name that won it\n+    for source, cfg, result in fetches:\n+        sr = SourceReport(name=source.name,\n+                          label=source.label or source.name,\n+                          result=result)\n+        report.sources.append(sr)\n+        if not result.ok:\n+            continue\n+\n+        try:\n+            override = source.override_existing(cfg)\n+        except Exception:  # noqa: BLE001\n+            override = False\n+\n+        for var, value in result.secrets.items():\n+            if not isinstance(var, str) or not isinstance(value, str):\n+                continue\n+            if not is_valid_env_name(var):\n+                sr.skipped_invalid.append(var)\n+                continue\n+            if var in protected:\n+                sr.skipped_protected.append(var)\n+                continue\n+            if var in claimed:\n+                sr.skipped_claimed.append(var)\n+                report.conflicts.append(\n+                    f\"{var}: kept value from {claimed[var]}; \"\n+                    f\"{source.name} also supplies it (first source wins — \"\n+                    \"remove one binding or reorder secrets.sources)\"\n+                )\n+                continue\n+            existed = bool(env.get(var))\n+            if existed and not override:\n+                sr.skipped_existing.append(var)\n+                continue\n+            env[var] = value\n+            claimed[var] = source.name\n+            sr.applied.append(var)\n+            report.provenance[var] = AppliedVar(\n+                name=var,\n+                source=source.name,\n+                shape=source.shape,\n+                overrode_env=existed,\n+            )\n+\n+    return report"]},{"file":"cli-config.yaml.example","hunks":["@@ -1380,3 +1380,50 @@ updates:\n #       # This is a CREDENTIAL: prefer setting HERMES_DASHBOARD_OIDC_CLIENT_SECRET\n #       # in ~/.hermes/.env over putting it here in config.yaml.\n #       #   client_secret: \"\"\n+\n+# =============================================================================\n+# External secret sources\n+# =============================================================================\n+# Pull provider credentials from external secret managers at process startup\n+# instead of storing them in ~/.hermes/.env. Only the manager's bootstrap\n+# credential (e.g. BWS_ACCESS_TOKEN / OP_SERVICE_ACCOUNT_TOKEN) lives in .env\n+# (or your shell / desktop session); everything else rotates centrally.\n+# Failures never block startup — Hermes warns once and continues with\n+# whatever .env already had.\n+#\n+# Multiple sources can be enabled at once:\n+#   - \"mapped\" sources (explicit VAR -> ref bindings, e.g. 1Password's env:\n+#     map) beat \"bulk\" sources (whole-project dumps like Bitwarden BSM)\n+#   - within a shape, the first source to claim a var wins; later claims\n+#     are skipped with a startup warning (never a silent clobber)\n+#   - a source's override_existing lets it beat .env/shell values, but\n+#     never another secret source's claim\n+# Docs: https://hermes-agent.nousresearch.com/docs/user-guide/secrets/\n+#\n+# secrets:\n+#   # Optional explicit ordering of enabled sources.\n+#   # sources: [onepassword, bitwarden]\n+#\n+#   # ---- Bitwarden Secrets Manager (bws CLI) --------------------------------\n+#   bitwarden:\n+#     enabled: false\n+#     access_token_env: [redacted:secret-assignment]   # bootstrap token, sourced from .env\n+#     project_id: \"\"                       # UUID of the BSM project to sync\n+#     server_url: \"\"                       # \"\" = US Cloud; EU/self-hosted URL otherwise\n+#     cache_ttl_seconds: 300               # 0 disables caching\n+#     override_existing: true              # BSM values win over existing env\n+#     auto_install: true                   # lazy-download bws into ~/.hermes/bin\n+#\n+#   # ---- 1Password (op CLI) -------------------------------------------------\n+#   onepassword:\n+#     enabled: false\n+#     # Map env-var names to op:// secret references. Each is resolved with a\n+#     # single `op read` at startup.\n+#     env:\n+#       OPENAI_API_KEY: \"[redacted:secret-assignment] key\"\n+#       ANTHROPIC_API_KEY: \"[redacted:secret-assignment]\"\n+#     account: \"\"                                   # op --account shorthand; \"\" = default\n+#     service_account_token_env: [redacted:secret-assignment]  # headless auth; unset = desktop session\n+#     binary_path: \"\"                               # \"\" = resolve op via PATH; else absolute path\n+#     cache_ttl_seconds: 300                        # 0 disables BOTH cache layers\n+#     override_existing: true                       # resolved values win over existing env"]},{"file":"hermes_cli/config.py","hunks":["@@ -3073,6 +3073,15 @@ def _ensure_hermes_home_managed(home: Path):\n     # Pull credentials from external secret managers at process startup\n     # rather than storing them in ~/.hermes/.env.\n     \"secrets\": {\n+        # Optional explicit ordering of enabled secret sources.  When\n+        # omitted, sources run in registration order (bundled first,\n+        # then plugin-registered).  Regardless of this list, \"mapped\"\n+        # sources (explicit VAR→ref bindings, e.g. a future 1Password\n+        # env: map) always take precedence over \"bulk\" sources\n+        # (project dumps like Bitwarden BSM), and the first source to\n+        # claim a var wins — later claims are skipped with a warning.\n+        # Example: sources: [onepassword, bitwarden]\n+        # \"sources\": [],\n         \"bitwarden\": {\n             # Master switch.  When false, BSM is never contacted and the\n             # bws binary is never auto-installed — same as not having","@@ -3104,6 +3113,34 @@ def _ensure_hermes_home_managed(home: Path):\n             # `hermes secrets bitwarden setup`.\n             \"server_url\": \"\",\n         },\n+        \"onepassword\": {\n+            # Master switch.  When false, the op CLI is never invoked —\n+            # same as not having this section at all.\n+            \"enabled\": False,\n+            # Mapping of env-var name → 1Password secret reference\n+            # (op://vault/item/field).  Each entry is resolved with a\n+            # single `op read` at startup.\n+            \"env\": {},\n+            # Optional account shorthand / sign-in address passed as\n+            # `op read --account <account>`.  Empty = op's default account.\n+            \"account\": \"\",\n+            # Name of the env var holding a 1Password service-account token\n+            # for headless auth.  Sourced from ~/.hermes/.env (or the shell)\n+            # and exported to the op child as OP_SERVICE_ACCOUNT_TOKEN.\n+            # Leave the var unset to use an interactive/desktop op session.\n+            \"service_account_token_env\": \"OP_SERVICE_ACCOUNT_TOKEN\",\n+            # Optional absolute path to the op binary.  When set it is used\n+            # verbatim (PATH is not consulted) — pin this to avoid trusting\n+            # whatever `op` appears first on PATH.  Empty = resolve via PATH.\n+            \"binary_path\": \"\",\n+            # Seconds to cache resolved values in-process and on disk.  0\n+            # disables BOTH cache layers (no values are written to disk).\n+            \"cache_ttl_seconds\": 300,\n+            # When True (default), resolved values overwrite existing env\n+            # vars so rotating a secret in 1Password takes effect on next\n+            # start.  Flip to false to let .env / shell exports win locally.\n+            \"override_existing\": True,\n+        },\n     },\n \n     # Paste collapse thresholds (TUI + CLI)."]},{"file":"hermes_cli/env_loader.py","hunks":["@@ -78,9 +78,17 @@ def format_secret_source_suffix(env_var: str) -> str:\n         return \"\"\n     if source == \"bitwarden\":\n         return \" (from Bitwarden)\"\n-    # Generic fallback — future-proofing for additional secret sources\n-    # (e.g. 1Password, HashiCorp Vault) without having to update every\n-    # call site.\n+    # Ask the registry for the source's human label (e.g. \"1Password\").\n+    # Fall back to the raw source name for labels the registry doesn't\n+    # know (stale provenance from an uninstalled plugin, tests).\n+    try:\n+        from agent.secret_sources.registry import get_source\n+\n+        registered = get_source(source)\n+        if registered is not None and registered.label:\n+            return f\" (from {registered.label})\"\n+    except Exception:  # noqa: BLE001 — label lookup must never raise\n+        pass\n     return f\" (from {source})\"\n \n ","@@ -238,6 +246,20 @@ def load_hermes_dotenv(\n         _load_dotenv_with_fallback(user_env, override=True)\n         loaded.append(user_env)\n \n+    # Load .op.env AFTER .env so that .env values win, but the bootstrap\n+    # token (OP_SERVICE_ACCOUNT_TOKEN) becomes available for\n+    # apply_onepassword_secrets() even in cron / subprocess environments\n+    # that inherit no shell state (no systemd EnvironmentFile, no op run).\n+    # .op.env is gitignored — the service-account token never enters the\n+    # committed .env file.\n+    # Users on systemd can alternatively use:\n+    #   EnvironmentFile=-/path/to/.hermes/.op.env\n+    # in their gateway unit, which takes precedence (override=False below\n+    # ensures .op.env never clobbers a token already in the environment).\n+    op_env = home_path / \".op.env\"\n+    if op_env.exists() and not os.environ.get(\"OP_SERVICE_ACCOUNT_TOKEN\"):\n+        _load_dotenv_with_fallback(op_env, override=False)\n+\n     if project_env_path and project_env_path.exists():\n         _load_dotenv_with_fallback(project_env_path, override=not loaded)\n         loaded.append(project_env_path)","@@ -281,21 +303,27 @@ def _apply_managed_env() -> None:\n \n \n def _apply_external_secret_sources(home_path: Path) -> None:\n-    \"\"\"Pull secrets from external sources (currently Bitwarden) into env.\n+    \"\"\"Pull secrets from every enabled external source into env.\n \n-    Runs AFTER dotenv loads so .env values are visible (we use them to\n-    locate the access token) but BEFORE the rest of Hermes reads\n+    Runs AFTER dotenv loads so .env values are visible (sources use them\n+    to locate bootstrap tokens) but BEFORE the rest of Hermes reads\n     ``os.environ`` for credentials.  Any failure here is logged and\n     swallowed — external secret sources must never block startup.\n \n+    The heavy lifting (source ordering, mapped-beats-bulk precedence,\n+    first-claim-wins conflict handling, override semantics, provenance)\n+    lives in ``agent.secret_sources.registry.apply_all``; this wrapper\n+    owns the once-per-HERMES_HOME guard, the post-apply ASCII\n+    sanitization sweep, the ``_SECRET_SOURCES`` provenance map that\n+    UI surfaces read, and the startup status lines.\n+\n     Idempotent within a process: subsequent calls for the same\n     ``home_path`` are no-ops.  ``load_hermes_dotenv()`` runs at import\n     time from several hot modules (cli.py, hermes_cli/main.py,\n     run_agent.py, trajectory_compressor.py, ...), so without this guard\n-    the Bitwarden status line would print 3-5x per CLI startup.  Use\n+    the status lines would print 3-5x per CLI startup.  Use\n     ``reset_secret_source_cache()`` if you need to force a re-pull\n-    (tests, future ``hermes secrets bitwarden sync`` from a long-running\n-    process).\n+    (tests, long-running processes after a config change).\n     \"\"\"\n     home_key = str(Path(home_path).resolve())\n     if home_key in _APPLIED_HOMES:","@@ -306,54 +334,45 @@ def _apply_external_secret_sources(home_path: Path) -> None:\n         cfg = _load_secrets_config(home_path)\n     except Exception:  # noqa: BLE001 — config errors must not block startup\n         return\n-\n-    bw_cfg = (cfg or {}).get(\"bitwarden\") or {}\n-    if not bw_cfg.get(\"enabled\"):\n+    if not cfg:\n         return\n \n     try:\n-        from agent.secret_sources.bitwarden import apply_bitwarden_secrets\n+        from agent.secret_sources.registry import apply_all\n     except ImportError:\n         return\n \n-    result = apply_bitwarden_secrets(\n-        enabled=True,\n-        access_token_env=[redacted:secret-assignment]\"access_token_env\", \"BWS_ACCESS_TOKEN\"),\n-        project_id=bw_cfg.get(\"project_id\", \"\"),\n-        override_existing=bool(bw_cfg.get(\"override_existing\", False)),\n-        cache_ttl_seconds=float(bw_cfg.get(\"cache_ttl_seconds\", 300)),\n-        auto_install=bool(bw_cfg.get(\"auto_install\", True)),\n-        server_url=str(bw_cfg.get(\"server_url\", \"\") or \"\").strip(),\n-        home_path=home_path,\n-    )\n-\n-    if result.applied:\n-        # Re-run the ASCII sanitization pass: BSM values are user-supplied\n-        # and might have the same copy-paste corruption as a manually\n-        # edited .env (see #6843).\n+    try:\n+        report = apply_all(cfg, home_path)\n+    except Exception:  # noqa: BLE001 — belt-and-braces; apply_all shouldn't raise\n+        return\n+\n+    if report.applied_any:\n+        # Re-run the ASCII sanitization pass: vault values are\n+        # user-supplied and might have the same copy-paste corruption as\n+        # a manually edited .env (see #6843).\n         _sanitize_loaded_credentials()\n-        # Remember where these came from so the setup / `hermes model`\n-        # flows can label detected credentials with \"(from Bitwarden)\" —\n-        # otherwise users see \"credentials ✓\" with no hint that the value\n-        # came from BSM rather than .env.\n-        for name in result.applied:\n-            _SECRET_SOURCES[name] = \"bitwarden\"\n-        print(\n-            f\"  Bitwarden Secrets Manager: applied {len(result.applied)} \"\n-            f\"secret{'s' if len(result.applied) != 1 else ''} \"\n-            f\"({', '.join(sorted(result.applied))})\",\n-            file=sys.stderr,\n-        )\n-    if result.error:\n-        print(\n-            f\"  Bitwarden Secrets Manager: {result.error}\",\n-            file=sys.stderr,\n-        )\n-    for warn in result.warnings:\n-        print(\n-            f\"  Bitwarden Secrets Manager: {warn}\",\n-            file=sys.stderr,\n-        )\n+        # Remember where each var came from so setup / `hermes model`\n+        # flows can label detected credentials with \"(from Bitwarden)\" /\n+        # \"(from 1Password)\" — otherwise users see \"credentials ✓\" with\n+        # no hint the value came from a vault rather than .env.\n+        for name, applied in report.provenance.items():\n+            _SECRET_SOURCES[name] = applied.source\n+\n+    for src in report.sources:\n+        if src.applied:\n+            print(\n+                f\"  {src.label}: applied {len(src.applied)} \"\n+                f\"secret{'s' if len(src.applied) != 1 else ''} \"\n+                f\"({', '.join(sorted(src.applied))})\",\n+                file=sys.stderr,\n+            )\n+        if src.result.error:\n+            print(f\"  {src.label}: {src.result.error}\", file=sys.stderr)\n+        for warn in src.result.warnings:\n+            print(f\"  {src.label}: {warn}\", file=sys.stderr)\n+    for conflict in report.conflicts:\n+        print(f\"  Secret sources: {conflict}\", file=sys.stderr)\n \n \n def _load_secrets_config(home_path: Path) -> dict:"]},{"file":"hermes_cli/main.py","hunks":["@@ -12787,16 +12787,16 @@ def main():\n     fallback_parser.set_defaults(func=cmd_fallback)\n \n     # =========================================================================\n-    # secrets command — external secret managers (currently: Bitwarden)\n+    # secrets command — external secret managers (Bitwarden, 1Password)\n     # =========================================================================\n     secrets_parser = [redacted:secret-assignment]\n         \"secrets\",\n-        help=\"Manage external secret sources (Bitwarden Secrets Manager)\",\n+        help=\"Manage external secret sources (Bitwarden, 1Password)\",\n         description=(\n             \"Pull API keys from an external secret manager at process startup \"\n-            \"instead of storing them in ~/.hermes/.env.  Currently supports \"\n-            \"Bitwarden Secrets Manager.  See: \"\n-            \"https://hermes-agent.nousresearch.com/docs/user-guide/secrets/bitwarden\"\n+            \"instead of storing them in ~/.hermes/.env.  Supports Bitwarden \"\n+            \"Secrets Manager and 1Password.  See: \"\n+            \"https://hermes-agent.nousresearch.com/docs/user-guide/secrets/\"\n         ),\n     )\n     secrets_subparsers = [redacted:secret-assignment]\"secrets_command\")","@@ -12807,16 +12807,27 @@ def main():\n         help=\"Bitwarden Secrets Manager integration\",\n     )\n \n+    secrets_op = [redacted:secret-assignment]\n+        \"onepassword\",\n+        aliases=[\"op\", \"1password\"],\n+        help=\"1Password (op:// references) integration\",\n+    )\n+\n     # Lazy import — only pays for itself when this subcommand is actually used.\n     from hermes_cli import secrets_cli as _secrets_cli\n+    from hermes_cli import onepassword_secrets_cli as _op_secrets_cli\n \n     _secrets_cli.register_cli(secrets_bw)\n+    _op_secrets_cli.register_cli(secrets_op)\n \n     def _dispatch_secrets(args):  # noqa: ANN001\n         sub = getattr(args, \"secrets_command\", None)\n         bw_sub = getattr(args, \"secrets_bw_command\", None)\n+        op_sub = getattr(args, \"secrets_op_command\", None)\n         if sub in (\"bitwarden\", \"bw\") and bw_sub is not None:\n             return args.func(args)\n+        if sub in (\"onepassword\", \"op\", \"1password\") and op_sub is not None:\n+            return args.func(args)\n         secrets_parser.print_help()\n         return 0\n "]},{"file":"hermes_cli/onepassword_secrets_cli.py","hunks":["@@ -0,0 +1,432 @@\n+\"\"\"CLI handlers for ``hermes secrets onepassword ...``.\n+\n+Subcommands:\n+    setup    — verify the op CLI, set account / token env var, enable\n+    status   — show config + op binary + auth + configured references\n+    set      — map an env var to an ``op://…`` reference\n+    remove   — drop a mapping\n+    sync     — resolve references now and show what would be applied (dry-run)\n+    disable  — flip ``secrets.onepassword.enabled`` to False\n+\n+Unlike Bitwarden, the ``op`` binary is NOT auto-installed: 1Password publishes\n+the CLI through OS package managers and signed installers, so Hermes expects\n+an already-installed, already-authenticated ``op`` and never downloads one.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import argparse\n+import os\n+import subprocess\n+from pathlib import Path\n+from typing import Optional\n+\n+from rich.console import Console\n+from rich.panel import Panel\n+from rich.table import Table\n+\n+from agent.secret_sources import onepassword as op_src\n+from hermes_cli.config import (\n+    get_env_path,\n+    load_config,\n+    save_config,\n+    save_env_value,\n+)\n+\n+_DEFAULT_TOKEN_ENV = \"[redacted:secret-assignment]\"\n+_DOCS_URL = \"https://developer.1password.com/docs/cli/get-started/\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Argparse wiring — called from hermes_cli.main\n+# ---------------------------------------------------------------------------\n+\n+\n+def register_cli(parent_parser: argparse.ArgumentParser) -> None:\n+    \"\"\"Attach the ``onepassword`` subcommand tree to a parent parser.\"\"\"\n+    sub = parent_parser.add_subparsers(dest=\"secrets_op_command\")\n+\n+    setup = sub.add_parser(\n+        \"setup\",\n+        help=\"Verify the op CLI, set account / token env var, and enable\",\n+    )\n+    setup.add_argument(\n+        \"--account\",\n+        help=\"1Password account shorthand or sign-in address (op --account)\",\n+    )\n+    setup.add_argument(\n+        \"--token-env\",\n+        help=f\"Env var holding a service-account token (default {_DEFAULT_TOKEN_ENV})\",\n+    )\n+    setup.add_argument(\n+        \"--token\",\n+        help=\"Service-account token to store in .env non-interactively\",\n+    )\n+    setup.add_argument(\n+        \"--binary-path\",\n+        help=\"Absolute path to the op binary (skips PATH lookup)\",\n+    )\n+    setup.set_defaults(func=cmd_setup)\n+\n+    status = sub.add_parser(\"status\", help=\"Show config + op binary + references\")\n+    status.set_defaults(func=cmd_status)\n+\n+    set_p = sub.add_parser(\"set\", help=\"Map an env var to an op:// reference\")\n+    set_p.add_argument(\"env_var\", help=\"Environment variable name, e.g. OPENAI_API_KEY\")\n+    set_p.add_argument(\"reference\", help=\"1Password reference, e.g. op://Private/OpenAI/api key\")\n+    set_p.set_defaults(func=cmd_set)\n+\n+    remove = sub.add_parser(\"remove\", help=\"Remove an env-var → reference mapping\")\n+    remove.add_argument(\"env_var\", help=\"Environment variable name to unmap\")\n+    remove.set_defaults(func=cmd_remove)\n+\n+    sync = sub.add_parser(\"sync\", help=\"Resolve references now and report what changed\")\n+    sync.add_argument(\n+        \"--apply\",\n+        action=\"store_true\",\n+        help=\"Actually export resolved values into the current shell (default: dry-run)\",\n+    )\n+    sync.set_defaults(func=cmd_sync)\n+\n+    disable = sub.add_parser(\"disable\", help=\"Turn off the 1Password integration\")\n+    disable.set_defaults(func=cmd_disable)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Handlers\n+# ---------------------------------------------------------------------------\n+\n+\n+def cmd_setup(args: argparse.Namespace) -> int:\n+    console = Console()\n+    console.print(\n+        Panel.fit(\n+            \"[bold]1Password secret source setup[/bold]\\n\\n\"\n+            \"Hermes resolves [cyan]op://vault/item/field[/cyan] references through your\\n\"\n+            \"already-installed, already-authenticated 1Password CLI (`op`).\\n\\n\"\n+            f\"Don't have it yet? Install + sign in: [cyan]{_DOCS_URL}[/cyan]\",\n+            border_style=\"cyan\",\n+        )\n+    )\n+\n+    cfg = load_config()\n+    op_cfg = cfg.setdefault(\"secrets\", {}).setdefault(\"onepassword\", {})\n+\n+    # ------------------------------------------------------------------ binary\n+    console.print()\n+    console.print(\"[bold]Step 1[/bold]  Locate the op CLI\")\n+    binary_path = (args.binary_path or op_cfg.get(\"binary_path\", \"\") or \"\").strip()\n+    binary = op_src.find_op(binary_path)\n+    if binary is None:\n+        if binary_path:\n+            console.print(f\"  [red]✗ {binary_path} is not an executable op binary.[/red]\")\n+        else:\n+            console.print(\"  [red]✗ op not found on PATH.[/red]\")\n+        console.print(f\"  Install the 1Password CLI: {_DOCS_URL}\")\n+        return 1\n+    console.print(f\"  [green]✓[/green] {binary}  ({_op_version(binary)})\")\n+    if binary_path:\n+        op_cfg[\"binary_path\"] = binary_path\n+\n+    # ----------------------------------------------------------------- account\n+    if args.account and args.account.strip():\n+        op_cfg[\"account\"] = args.account.strip()\n+        console.print(f\"  Account: [cyan]{op_cfg['account']}[/cyan]\")\n+\n+    # ------------------------------------------------------------------- token\n+    console.print()\n+    console.print(\"[bold]Step 2[/bold]  Authentication\")\n+    token_env = [redacted:secret-assignment] or op_cfg.get(\"service_account_token_env\")\n+                 or _DEFAULT_TOKEN_ENV).strip()\n+    op_cfg[\"service_account_token_env\"] = token_env\n+\n+    token = [redacted:secret-assignment] or \"\").strip()\n+    if token:\n+        save_env_value(token_env, token)\n+        os.environ[token_env] = token\n+        console.print(f\"  [green]✓[/green] service-account token stored in \"\n+                      f\"{get_env_path()} as {token_env}\")\n+    elif os.environ.get(token_env):\n+        console.print(f\"  [green]✓[/green] using service-account token from {token_env}\")\n+    else:\n+        who = _op_whoami(binary, op_cfg.get(\"account\", \"\"))\n+        if who:\n+            console.print(f\"  [green]✓[/green] using existing op session ({who})\")\n+        else:\n+            console.print(\n+                \"  [yellow]No service-account token and no active op session \"\n+                \"detected.[/yellow]\\n\"\n+                \"  Either run [cyan]op signin[/cyan] (desktop/interactive) or set a \"\n+                f\"service-account token in {token_env}, then re-run status.\"\n+            )\n+\n+    # ----------------------------------------------------------------- enable\n+    op_cfg[\"enabled\"] = True\n+    op_cfg.setdefault(\"env\", {})\n+    op_cfg.setdefault(\"cache_ttl_seconds\", 300)\n+    op_cfg.setdefault(\"override_existing\", True)\n+    save_config(cfg)\n+\n+    console.print()\n+    console.print(\"[green]✓ 1Password secret source is enabled.[/green]\")\n+    console.print(\n+        \"  Map credentials:  [redacted:secret-assignment] secrets onepassword set OPENAI_API_KEY \"\n+        \"\\\"op://Private/OpenAI/api key\\\"[/cyan]\\n\"\n+        \"  Preview:          [cyan]hermes secrets onepassword sync[/cyan]\\n\"\n+        \"  Status:           [cyan]hermes secrets onepassword status[/cyan]\"\n+    )\n+    return 0\n+\n+\n+def cmd_status(args: argparse.Namespace) -> int:\n+    console = Console()\n+    cfg = load_config()\n+    op_cfg = (cfg.get(\"secrets\") or {}).get(\"onepassword\") or {}\n+\n+    enabled = bool(op_cfg.get(\"enabled\"))\n+    account = str(op_cfg.get(\"account\", \"\") or \"\").strip()\n+    token_env = [redacted:secret-assignment]\"service_account_token_env\", _DEFAULT_TOKEN_ENV)\n+    binary_path = str(op_cfg.get(\"binary_path\", \"\") or \"\").strip()\n+    references = op_cfg.get(\"env\") if isinstance(op_cfg.get(\"env\"), dict) else {}\n+    token_set = [redacted:secret-assignment]\n+\n+    binary = op_src.find_op(binary_path)\n+\n+    table = Table(show_header=False, box=None, padding=(0, 2))\n+    table.add_column(\"\", style=\"bold\")\n+    table.add_column(\"\")\n+    table.add_row(\"Enabled\", _yn(enabled))\n+    table.add_row(\"Account\", account or \"[dim]default[/dim]\")\n+    table.add_row(\"Token env var\", token_env)\n+    table.add_row(\"Token in env\", _yn(token_set))\n+    table.add_row(\"Override existing\", _yn(bool(op_cfg.get(\"override_existing\", True))))\n+    table.add_row(\"Cache TTL (s)\", str(op_cfg.get(\"cache_ttl_seconds\", 300)))\n+    if binary:\n+        table.add_row(\"op binary\", f\"{binary} ({_op_version(binary)})\")\n+    else:\n+        table.add_row(\"op binary\", \"[yellow]not found[/yellow]\")\n+    table.add_row(\"References\", str(len(references)))\n+\n+    console.print(Panel(table, title=\"1Password secret source\", border_style=\"cyan\"))\n+\n+    if references:\n+        ref_table = Table(show_header=True, header_style=\"bold\")\n+        ref_table.add_column(\"Env var\", style=\"cyan\")\n+        ref_table.add_column(\"Reference\")\n+        for name in sorted(references):\n+            ref_table.add_row(name, str(references[name]))\n+        console.print(ref_table)\n+\n+    if not enabled:\n+        console.print(\"\\n  Run [cyan]hermes secrets onepassword setup[/cyan] to enable.\")\n+        return 0\n+    if binary and not token_set:\n+        who = _op_whoami(binary, account)\n+        if who:\n+            console.print(f\"\\n  [green]Active op session:[/green] {who}\")\n+        else:\n+            console.print(\n+                f\"\\n  [yellow]No active op session and {token_env} is unset — \"\n+                \"Hermes will warn and skip 1Password on next startup.[/yellow]\"\n+            )\n+    if not references:\n+        console.print(\n+            \"\\n  [yellow]No references mapped yet.[/yellow]  Add one: \"\n+            \"[cyan]hermes secrets onepassword set ENV_VAR \\\"op://…\\\"[/cyan]\"\n+        )\n+    return 0\n+\n+\n+def cmd_set(args: argparse.Namespace) -> int:\n+    console = Console()\n+    # Reuse the backend validator so the CLI and startup paths agree on what a\n+    # valid reference is — and store the *validated/stripped* value, not the\n+    # raw arg (so trailing whitespace never lands in config.yaml).\n+    valid, warnings = op_src._validate_references({args.env_var: args.reference})\n+    if args.env_var not in valid:\n+        for w in warnings:\n+            console.print(f\"[red]{w}[/red]\")\n+        return 1\n+\n+    cfg = load_config()\n+    op_cfg = cfg.setdefault(\"secrets\", {}).setdefault(\"onepassword\", {})\n+    env_map = op_cfg.get(\"env\")\n+    if not isinstance(env_map, dict):\n+        env_map = {}\n+        op_cfg[\"env\"] = env_map\n+    env_map[args.env_var] = valid[args.env_var]\n+    save_config(cfg)\n+    console.print(\n+        f\"[green]✓[/green] mapped [cyan]{args.env_var}[/cyan] → \"\n+        f\"{valid[args.env_var]}\"\n+    )\n+    if not op_cfg.get(\"enabled\"):\n+        console.print(\n+            \"  [yellow]Note: the integration is disabled — run \"\n+            \"[cyan]hermes secrets onepassword setup[/cyan] to turn it on.[/yellow]\"\n+        )\n+    return 0\n+\n+\n+def cmd_remove(args: argparse.Namespace) -> int:\n+    console = Console()\n+    cfg = load_config()\n+    op_cfg = cfg.setdefault(\"secrets\", {}).setdefault(\"onepassword\", {})\n+    env_map = op_cfg.get(\"env\")\n+    if not isinstance(env_map, dict) or args.env_var not in env_map:\n+        console.print(f\"[yellow]{args.env_var} is not mapped.[/yellow]\")\n+        return 1\n+    del env_map[args.env_var]\n+    save_config(cfg)\n+    console.print(f\"[green]✓[/green] removed mapping for [cyan]{args.env_var}[/cyan]\")\n+    return 0\n+\n+\n+def cmd_sync(args: argparse.Namespace) -> int:\n+    console = Console()\n+    cfg = load_config()\n+    op_cfg = (cfg.get(\"secrets\") or {}).get(\"onepassword\") or {}\n+    if not op_cfg.get(\"enabled\"):\n+        console.print(\n+            \"[yellow]1Password integration is disabled.  Run \"\n+            \"`hermes secrets onepassword setup` first.[/yellow]\"\n+        )\n+        return 1\n+\n+    references = op_cfg.get(\"env\") if isinstance(op_cfg.get(\"env\"), dict) else {}\n+    if not references:\n+        console.print(\n+            \"[yellow]No op:// references configured.  Add one with \"\n+            \"`hermes secrets onepassword set ENV_VAR \\\"op://…\\\"`.[/yellow]\"\n+        )\n+        return 0\n+\n+    account = str(op_cfg.get(\"account\", \"\") or \"\").strip()\n+    token_env = [redacted:secret-assignment]\"service_account_token_env\", _DEFAULT_TOKEN_ENV)\n+    binary_path = str(op_cfg.get(\"binary_path\", \"\") or \"\").strip()\n+\n+    # --apply delegates to the same code path startup uses, so the skip /\n+    # override / token-guard policy lives in exactly one place.\n+    if args.apply:\n+        result = op_src.apply_onepassword_secrets(\n+            enabled=True,\n+            env=references,\n+            account=account,\n+            service_account_token_env=[redacted:secret-assignment]\n+            binary_path=binary_path,\n+            override_existing=bool(op_cfg.get(\"override_existing\", True)),\n+            cache_ttl_seconds=0,  # an explicit sync always resolves fresh\n+        )\n+        if result.error:\n+            console.print(f\"[red]{result.error}[/red]\")\n+            return 1\n+        table = Table(show_header=True, header_style=\"bold\")\n+        table.add_column(\"Env var\", style=\"cyan\")\n+        table.add_column(\"Action\")\n+        for name in sorted(result.applied):\n+            table.add_row(name, \"[green]exported[/green]\")\n+        for name in sorted(result.skipped):\n+            table.add_row(name, \"[dim]skipped (already set / token var)[/dim]\")\n+        console.print(table)\n+        for w in result.warnings:\n+            console.print(f\"[yellow]warning:[/yellow] {w}\")\n+        console.print(\n+            f\"\\n  [green]Exported {len(result.applied)} secret(s) into current \"\n+            \"process.[/green]\"\n+        )\n+        return 0\n+\n+    # Dry-run: resolve fresh (no cache) and preview, mutating nothing.\n+    try:\n+        secrets, warnings = op_src.fetch_onepassword_secrets(\n+            references=references,\n+            account=account,\n+            token_env=[redacted:secret-assignment]\n+            binary_path=binary_path,\n+            use_cache=False,\n+        )\n+    except RuntimeError as exc:\n+        console.print(f\"[red]{exc}[/red]\")\n+        return 1\n+\n+    override = bool(op_cfg.get(\"override_existing\", True))\n+    table = Table(show_header=True, header_style=\"bold\")\n+    table.add_column(\"Env var\", style=\"cyan\")\n+    table.add_column(\"Action\")\n+    for name in sorted(references):\n+        if name == token_env:\n+            table.add_row(name, \"[dim]skip (token var)[/dim]\")\n+        elif name not in secrets:\n+            table.add_row(name, \"[red]unresolved (see warnings)[/red]\")\n+        elif os.environ.get(name) and not override:\n+            table.add_row(name, \"[dim]skip (already set)[/dim]\")\n+        else:\n+            already = bool(os.environ.get(name))\n+            table.add_row(\n+                name,\n+                \"[green]would export[/green]\" + (\" (overrides)\" if already else \"\"),\n+            )\n+    console.print(table)\n+    for w in warnings:\n+        console.print(f\"[yellow]warning:[/yellow] {w}\")\n+    console.print(\n+        \"\\n  This was a dry-run — references resolve automatically on the next \"\n+        \"[cyan]hermes[/cyan] invocation.  Re-run with [cyan]--apply[/cyan] to export \"\n+        \"into the current shell instead.\"\n+    )\n+    return 0\n+\n+\n+def cmd_disable(args: argparse.Namespace) -> int:\n+    console = Console()\n+    cfg = load_config()\n+    op_cfg = cfg.setdefault(\"secrets\", {}).setdefault(\"onepassword\", {})\n+    op_cfg[\"enabled\"] = False\n+    save_config(cfg)\n+    console.print(\n+        \"[green]Disabled.[/green]  1Password references will NOT be resolved on the \"\n+        \"next Hermes invocation.\\n\"\n+        \"  Your reference mappings are left in config.yaml — remove them with \"\n+        \"[cyan]hermes secrets onepassword remove ENV_VAR[/cyan] if you no longer \"\n+        \"need them.\"\n+    )\n+    return 0\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+def _yn(b: bool) -> str:\n+    return \"[green]yes[/green]\" if b else \"[dim]no[/dim]\"\n+\n+\n+def _op_version(binary: Path) -> str:\n+    try:\n+        res = subprocess.run(\n+            [str(binary), \"--version\"],\n+            capture_output=True,\n+            text=True,\n+            timeout=5,\n+        )\n+        if res.returncode == 0:\n+            return (res.stdout or res.stderr).strip().splitlines()[0]\n+    except (OSError, subprocess.TimeoutExpired):\n+        pass\n+    return \"version unknown\"\n+\n+\n+def _op_whoami(binary: Path, account: str) -> Optional[str]:\n+    \"\"\"Return a short identity string if op is authenticated, else None.\"\"\"\n+    cmd = [str(binary), \"whoami\"]\n+    if account:\n+        cmd += [\"--account\", account]\n+    try:\n+        res = subprocess.run(cmd, capture_output=True, text=True, timeout=10)\n+    except (OSError, subprocess.TimeoutExpired):\n+        return None\n+    if res.returncode != 0:\n+        return None\n+    out = (res.stdout or \"\").strip()\n+    return out.replace(\"\\n\", \" \")[:120] or \"authenticated\""]},{"file":"hermes_cli/plugins.py","hunks":["@@ -795,6 +795,53 @@ def register_browser_provider(self, provider) -> None:\n             self.manifest.name, provider.name,\n         )\n \n+    # -- secret source registration -------------------------------------------\n+\n+    def register_secret_source(self, source) -> None:\n+        \"\"\"Register an external secret-manager backend.\n+\n+        ``source`` must be an instance of\n+        :class:`agent.secret_sources.base.SecretSource`.  Registered\n+        sources run during ``load_hermes_dotenv()`` startup — after\n+        ``~/.hermes/.env`` loads, before Hermes reads credentials — when\n+        their ``secrets.<source.name>`` config section is enabled.  The\n+        orchestrator (``agent.secret_sources.registry.apply_all``) owns\n+        ordering, mapped-vs-bulk precedence, conflict warnings, and\n+        provenance; the source only fetches.\n+\n+        NOTE ON TIMING: plugin discovery happens later in startup than\n+        the first ``load_hermes_dotenv()`` call, so a plugin-registered\n+        source is not consulted by the initial env load of the process\n+        that discovers it.  It IS consulted by every subsequently\n+        spawned Hermes process (gateway children, cron sessions,\n+        subagents), and immediately after a\n+        ``reset_secret_source_cache()`` re-pull.  Plugin sources are\n+        therefore best for supplying credentials to the running fleet;\n+        the bundled sources cover first-process bootstrap.\n+\n+        Contract requirements (rejected with a warning otherwise):\n+        inherit from ``SecretSource``, ``api_version`` matching\n+        ``SECRET_SOURCE_API_VERSION``, lowercase unique ``name``,\n+        ``shape`` of ``\"mapped\"`` or ``\"bulk\"``, unique ``scheme`` (when\n+        set), and a ``fetch()`` that never raises and never prompts.\n+        See the base-module docstring for the full contract.\n+        \"\"\"\n+        from agent.secret_sources.base import SecretSource\n+        from agent.secret_sources.registry import register_source\n+\n+        if not isinstance(source, SecretSource):\n+            logger.warning(\n+                \"Plugin '%s' tried to register a secret source that does \"\n+                \"not inherit from SecretSource. Ignoring.\",\n+                self.manifest.name,\n+            )\n+            return\n+        if register_source(source):\n+            logger.info(\n+                \"Plugin '%s' registered secret source: %s\",\n+                self.manifest.name, source.name,\n+            )\n+\n     # -- TTS provider registration -------------------------------------------\n \n     def register_tts_provider(self, provider) -> None:"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"hwrdprkns\",  # PR #36896 salvage (secrets: [redacted:secret-assignment] op:// secret source + shared _cache substrate, adapted onto the SecretSource interface)\n     \"[redacted:email]\": \"isheng-eqi\",  # PR #59428 salvage (cron: reject past one-shot timestamps in update_job fallback + resume_job; #59395). Also PR #59446 salvage (cron: advance one-shot next_run_at before dispatch so concurrent gateway+desktop schedulers can't double-execute; #59229).\n     \"[redacted:email]\": \"derek2000139\",  # PR #57838 salvage (desktop/windows: pre-write update marker before quit dwell so the renderer's waitForUpdateToFinish gate parks instead of respawning a backend that re-locks venv .pyd files mid-update)\n     \"[redacted:email]\": \"AndreasHiltner\",  # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep)"]},{"file":"tests/secret_sources/conformance.py","hunks":["@@ -0,0 +1,123 @@\n+\"\"\"Conformance kit for :class:`agent.secret_sources.base.SecretSource`.\n+\n+Any secret-source backend — bundled or external plugin — can validate\n+itself against the contract by subclassing :class:`SecretSourceConformance`\n+and providing a ``source`` fixture (plus optional per-source config\n+fixtures).  Example::\n+\n+    from tests.secret_sources.conformance import SecretSourceConformance\n+\n+    class TestMySourceConformance(SecretSourceConformance):\n+        @pytest.fixture\n+        def source(self):\n+            return MySource()\n+\n+The checks encode the parts of the contract that break OTHER people\n+when violated: never raising, never prompting (stdin closed), respecting\n+disabled config, valid identity attributes, and orchestrator\n+compatibility.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from pathlib import Path\n+\n+import pytest\n+\n+from agent.secret_sources.base import (\n+    SECRET_SOURCE_API_VERSION,\n+    FetchResult,\n+    SecretSource,\n+)\n+from agent.secret_sources.registry import (\n+    _reset_registry_for_tests,\n+    apply_all,\n+    register_source,\n+)\n+\n+\n+class SecretSourceConformance:\n+    \"\"\"Base class of contract checks; subclass and provide ``source``.\"\"\"\n+\n+    @pytest.fixture\n+    def source(self) -> SecretSource:  # pragma: no cover — must override\n+        raise NotImplementedError(\"conformance subclasses must provide a source fixture\")\n+\n+    @pytest.fixture\n+    def minimal_cfg(self) -> dict:\n+        \"\"\"An enabled-but-unconfigured section — the common misconfig case.\"\"\"\n+        return {\"enabled\": True}\n+\n+    # -- identity ----------------------------------------------------------\n+\n+    def test_name_is_lowercase_identifier(self, source):\n+        assert source.name, \"source.name must be non-empty\"\n+        assert source.name == source.name.lower()\n+        assert source.name.replace(\"_\", \"\").isalnum()\n+\n+    def test_label_present(self, source):\n+        assert source.label, \"source.label must be a human-readable name\"\n+\n+    def test_shape_valid(self, source):\n+        assert source.shape in (\"mapped\", \"bulk\")\n+\n+    def test_api_version_current(self, source):\n+        assert source.api_version == SECRET_SOURCE_API_VERSION\n+\n+    # -- contract behavior --------------------------------------------------\n+\n+    def test_fetch_never_raises_on_malformed_config(self, source, tmp_path):\n+        \"\"\"Every degenerate config shape must produce a FetchResult, not a raise.\"\"\"\n+        for cfg in ({}, {\"enabled\": True}, {\"enabled\": True, \"env\": \"not-a-dict\"},\n+                    {\"enabled\": True, \"cache_ttl_seconds\": \"bogus\"}, None):\n+            result = source.fetch(cfg if isinstance(cfg, dict) else {}, tmp_path)\n+            assert isinstance(result, FetchResult), (\n+                f\"fetch() returned {type(result).__name__} for cfg={cfg!r}\"\n+            )\n+\n+    def test_fetch_unconfigured_reports_error_not_secrets(self, source, tmp_path,\n+                                                          minimal_cfg, monkeypatch):\n+        \"\"\"enabled=true with nothing else set must fail cleanly with a kind.\"\"\"\n+        result = source.fetch(minimal_cfg, tmp_path)\n+        assert isinstance(result, FetchResult)\n+        if not result.ok:\n+            assert result.error_kind is not None, (\n+                \"errors must carry a machine-readable ErrorKind\"\n+            )\n+            assert not result.secrets\n+\n+    def test_disabled_by_default(self, source):\n+        assert source.is_enabled({}) is False\n+        assert source.is_enabled({\"enabled\": False}) is False\n+\n+    def test_timeout_is_positive(self, source, minimal_cfg):\n+        assert source.fetch_timeout_seconds(minimal_cfg) > 0\n+        # Garbage config must not break the timeout accessor either.\n+        assert source.fetch_timeout_seconds({\"timeout_seconds\": \"junk\"}) > 0\n+\n+    def test_protected_vars_are_valid_names(self, source, minimal_cfg):\n+        from agent.secret_sources.base import is_valid_env_name\n+\n+        for var in source.protected_env_vars(minimal_cfg):\n+            assert is_valid_env_name(var)\n+\n+    # -- orchestrator compatibility ------------------------------------------\n+\n+    def test_registers_and_applies_via_orchestrator(self, source, tmp_path,\n+                                                    monkeypatch):\n+        \"\"\"The source must survive a full apply_all() pass without breaking it.\"\"\"\n+        _reset_registry_for_tests()\n+        # Prevent the bundled sources from interfering.\n+        monkeypatch.setattr(\n+            \"agent.secret_sources.registry._ensure_builtin_sources\", lambda: None\n+        )\n+        try:\n+            assert register_source(source), \"register_source() rejected the source\"\n+            env: dict = {}\n+            report = apply_all(\n+                {source.name: {\"enabled\": True}}, tmp_path, environ=env\n+            )\n+            names = [sr.name for sr in report.sources]\n+            assert source.name in names\n+        finally:\n+            _reset_registry_for_tests()"]},{"file":"tests/secret_sources/test_secret_source_registry.py","hunks":["@@ -0,0 +1,600 @@\n+\"\"\"Tests for the secret-source contract + orchestrator.\n+\n+Covers: registration gating (API version, name/scheme uniqueness, shape),\n+apply_all precedence (mapped beats bulk, first-wins, override_existing,\n+protected vars), conflict surfacing, timeout enforcement, provenance,\n+and Bitwarden's SecretSource adapter — plus the conformance kit run\n+against the bundled Bitwarden source.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import sys\n+import time\n+from pathlib import Path\n+\n+import pytest\n+\n+ROOT = Path(__file__).resolve().parents[2]\n+if str(ROOT) not in sys.path:\n+    sys.path.insert(0, str(ROOT))\n+\n+from agent.secret_sources.base import (  # noqa: E402\n+    SECRET_SOURCE_API_VERSION,\n+    ErrorKind,\n+    FetchResult,\n+    SecretSource,\n+    is_valid_env_name,\n+    run_secret_cli,\n+    scrub_ansi,\n+)\n+from agent.secret_sources import registry as reg  # noqa: E402\n+from agent.secret_sources.bitwarden import BitwardenSource  # noqa: E402\n+from tests.secret_sources.conformance import SecretSourceConformance  # noqa: E402\n+\n+\n[redacted:email](autouse=True)\n+def _clean_registry(monkeypatch):\n+    \"\"\"Each test starts with an empty registry and no builtin auto-load.\"\"\"\n+    reg._reset_registry_for_tests()\n+    monkeypatch.setattr(reg, \"_ensure_builtin_sources\", lambda: None)\n+    yield\n+    reg._reset_registry_for_tests()\n+\n+\n+def _make_source(\n+    name=\"dummy\",\n+    shape=\"mapped\",\n+    secrets=None,\n+    error=None,\n+    error_kind=None,\n+    scheme=None,\n+    override=False,\n+    protected=(),\n+    api_version=SECRET_SOURCE_API_VERSION,\n+    fetch_fn=None,\n+):\n+    \"\"\"Build a minimal conforming source for orchestrator tests.\"\"\"\n+\n+    class _Src(SecretSource):\n+        def fetch(self, cfg, home_path):\n+            if fetch_fn is not None:\n+                return fetch_fn(cfg, home_path)\n+            res = FetchResult()\n+            if error:\n+                res.error = error\n+                res.error_kind = error_kind or ErrorKind.INTERNAL\n+            else:\n+                res.secrets = [redacted:secret-assignment] or {})\n+            return res\n+\n+        def override_existing(self, cfg):\n+            return override\n+\n+        def protected_env_vars(self, cfg):\n+            return frozenset(protected)\n+\n+    _Src.name = name\n+    _Src.label = name.title()\n+    _Src.shape = shape\n+    _Src.scheme = scheme\n+    _Src.api_version = api_version\n+    return _Src()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Registration gating\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestRegistration:\n+    def test_registers_conforming_source(self):\n+        assert reg.register_source(_make_source()) is True\n+        assert reg.get_source(\"dummy\") is not None\n+\n+    def test_rejects_non_secretsource_instance(self):\n+        assert reg.register_source(object()) is False\n+\n+    def test_rejects_wrong_api_version(self):\n+        src = _make_source(api_version=SECRET_SOURCE_API_VERSION + 1)\n+        assert reg.register_source(src) is False\n+\n+    def test_rejects_invalid_name(self):\n+        assert reg.register_source(_make_source(name=\"Bad Name\")) is False\n+        assert reg.register_source(_make_source(name=\"\")) is False\n+        assert reg.register_source(_make_source(name=\"UPPER\")) is False\n+\n+    def test_rejects_invalid_shape(self):\n+        assert reg.register_source(_make_source(shape=\"sideways\")) is False\n+\n+    def test_rejects_duplicate_name_without_replace(self):\n+        assert reg.register_source(_make_source(name=\"dup\")) is True\n+        assert reg.register_source(_make_source(name=\"dup\")) is False\n+        assert reg.register_source(_make_source(name=\"dup\"), replace=True) is True\n+\n+    def test_rejects_scheme_collision_across_names(self):\n+        assert reg.register_source(_make_source(name=\"one\", scheme=\"op\")) is True\n+        assert reg.register_source(_make_source(name=\"two\", scheme=\"op\")) is False\n+\n+    def test_same_name_replace_keeps_scheme(self):\n+        assert reg.register_source(_make_source(name=\"one\", scheme=\"op\")) is True\n+        assert reg.register_source(\n+            _make_source(name=\"one\", scheme=\"op\"), replace=True\n+        ) is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# apply_all: precedence, conflicts, protection\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestApplyAll:\n+    def test_disabled_sources_do_not_run(self, tmp_path):\n+        called = []\n+\n+        def _fetch(cfg, home):\n+            called.append(True)\n+            return FetchResult(secrets={\"A\": \"1\"})\n+\n+        reg.register_source(_make_source(fetch_fn=_fetch))\n+        env: dict = {}\n+        report = reg.apply_all({\"dummy\": {\"enabled\": False}}, tmp_path, environ=env)\n+        assert not called\n+        assert not report.sources\n+        assert env == {}\n+\n+    def test_applies_secrets_and_records_provenance(self, tmp_path):\n+        reg.register_source(_make_source(secrets={\"API_KEY\": \"v1\"}))\n+        env: dict = {}\n+        report = reg.apply_all({\"dummy\": {\"enabled\": True}}, tmp_path, environ=env)\n+        assert env[\"API_KEY\"] == \"v1\"\n+        assert report.provenance[\"API_KEY\"].source == \"dummy\"\n+        assert report.provenance[\"API_KEY\"].shape == \"mapped\"\n+        assert report.provenance[\"API_KEY\"].overrode_env is False\n+\n+    def test_existing_env_wins_without_override(self, tmp_path):\n+        reg.register_source(_make_source(secrets={\"API_KEY\": \"vault\"}))\n+        env = {\"API_KEY\": \"dotenv\"}\n+        report = reg.apply_all({\"dummy\": {\"enabled\": True}}, tmp_path, environ=env)\n+        assert env[\"API_KEY\"] == \"dotenv\"\n+        assert \"API_KEY\" in report.sources[0].skipped_existing\n+\n+    def test_override_existing_beats_env_and_is_attributed(self, tmp_path):\n+        reg.register_source(_make_source(secrets={\"API_KEY\": \"vault\"}, override=True))\n+        env = {\"API_KEY\": \"dotenv\"}\n+        report = reg.apply_all({\"dummy\": {\"enabled\": True}}, tmp_path, environ=env)\n+        assert env[\"API_KEY\"] == \"vault\"\n+        assert report.provenance[\"API_KEY\"].overrode_env is True\n+\n+    def test_mapped_beats_bulk_regardless_of_order(self, tmp_path):\n+        reg.register_source(\n+            _make_source(name=\"bulky\", shape=\"bulk\", secrets={\"K\": \"bulk\"})\n+        )\n+        reg.register_source(\n+            _make_source(name=\"mappy\", shape=\"mapped\", secrets={\"K\": \"mapped\"})\n+        )\n+        env: dict = {}\n+        # bulk listed first in sources order — mapped must still win.\n+        report = reg.apply_all(\n+            {\"sources\": [\"bulky\", \"mappy\"],\n+             \"bulky\": {\"enabled\": True}, \"mappy\": {\"enabled\": True}},\n+            tmp_path, environ=env,\n+        )\n+        assert env[\"K\"] == \"mapped\"\n+        assert report.provenance[\"K\"].source == \"mappy\"\n+        assert report.conflicts, \"shadowed bulk claim must surface a warning\"\n+\n+    def test_first_source_wins_within_shape(self, tmp_path):\n+        reg.register_source(_make_source(name=\"alpha\", secrets={\"K\": \"a\"}))\n+        reg.register_source(_make_source(name=\"beta\", secrets={\"K\": \"b\"}))\n+        env: dict = {}\n+        report = reg.apply_all(\n+            {\"sources\": [\"beta\", \"alpha\"],\n+             \"alpha\": {\"enabled\": True}, \"beta\": {\"enabled\": True}},\n+            tmp_path, environ=env,\n+        )\n+        assert env[\"K\"] == \"b\"  # beta listed first\n+        assert report.provenance[\"K\"].source == \"beta\"\n+        beta_first = [s for s in report.sources if s.name == \"alpha\"][0]\n+        assert \"K\" in beta_first.skipped_claimed\n+\n+    def test_cross_source_override_never_clobbers_prior_claim(self, tmp_path):\n+        \"\"\"override_existing beats .env, NEVER another source's claim.\"\"\"\n+        reg.register_source(_make_source(name=\"alpha\", secrets={\"K\": \"a\"}))\n+        reg.register_source(\n+            _make_source(name=\"beta\", secrets={\"K\": \"b\"}, override=True)\n+        )\n+        env: dict = {}\n+        report = reg.apply_all(\n+            {\"sources\": [\"alpha\", \"beta\"],\n+             \"alpha\": {\"enabled\": True}, \"beta\": {\"enabled\": True}},\n+            tmp_path, environ=env,\n+        )\n+        assert env[\"K\"] == \"a\"\n+        assert report.conflicts\n+\n+    def test_protected_vars_never_overwritten_by_any_source(self, tmp_path):\n+        reg.register_source(\n+            _make_source(name=\"alpha\", secrets={\"BOOT_TOKEN\": \"evil\"},\n+                         override=True, protected=(\"BOOT_TOKEN\",))\n+        )\n+        env = {\"BOOT_TOKEN\": \"real\"}\n+        report = reg.apply_all({\"alpha\": {\"enabled\": True}}, tmp_path, environ=env)\n+        assert env[\"BOOT_TOKEN\"] == \"real\"\n+        assert \"BOOT_TOKEN\" in report.sources[0].skipped_protected\n+\n+    def test_invalid_env_names_skipped(self, tmp_path):\n+        reg.register_source(\n+            _make_source(secrets={\"GOOD_NAME\": \"v\", \"bad-name\": \"v\", \"1BAD\": \"v\"})\n+        )\n+        env: dict = {}\n+        report = reg.apply_all({\"dummy\": {\"enabled\": True}}, tmp_path, environ=env)\n+        assert \"GOOD_NAME\" in env and \"bad-name\" not in env and \"1BAD\" not in env\n+        assert set(report.sources[0].skipped_invalid) == {\"bad-name\", \"1BAD\"}\n+\n+    def test_failed_source_does_not_block_others(self, tmp_path):\n+        reg.register_source(\n+            _make_source(name=\"broken\", error=\"boom\", error_kind=ErrorKind.NETWORK)\n+        )\n+        reg.register_source(_make_source(name=\"works\", secrets={\"K\": \"v\"}))\n+        env: dict = {}\n+        report = reg.apply_all(\n+            {\"broken\": {\"enabled\": True}, \"works\": {\"enabled\": True}},\n+            tmp_path, environ=env,\n+        )\n+        assert env[\"K\"] == \"v\"\n+        broken = [s for s in report.sources if s.name == \"broken\"][0]\n+        assert broken.result.error_kind is ErrorKind.NETWORK\n+\n+    def test_raising_fetch_contained_as_internal_error(self, tmp_path):\n+        def _explode(cfg, home):\n+            raise ValueError(\"plugin bug\")\n+\n+        reg.register_source(_make_source(name=\"buggy\", fetch_fn=_explode))\n+        env: dict = {}\n+        report = reg.apply_all({\"buggy\": {\"enabled\": True}}, tmp_path, environ=env)\n+        assert report.sources[0].result.error_kind is ErrorKind.INTERNAL\n+        assert \"plugin bug\" in report.sources[0].result.error\n+\n+    def test_wrong_return_type_contained(self, tmp_path):\n+        reg.register_source(\n+            _make_source(name=\"liar\", fetch_fn=lambda cfg, home: {\"not\": \"a result\"})\n+        )\n+        report = reg.apply_all({\"liar\": {\"enabled\": True}}, tmp_path, environ={})\n+        assert report.sources[0].result.error_kind is ErrorKind.INTERNAL\n+\n+    def test_timeout_enforced(self, tmp_path):\n+        def _slow(cfg, home):\n+            time.sleep(5)\n+            return FetchResult(secrets={\"K\": \"late\"})\n+\n+        src = _make_source(name=\"slow\", fetch_fn=_slow)\n+        src.fetch_timeout_seconds = lambda cfg: 0.2\n+        reg.register_source(src)\n+        env: dict = {}\n+        start = time.monotonic()\n+        report = reg.apply_all({\"slow\": {\"enabled\": True}}, tmp_path, environ=env)\n+        assert time.monotonic() - start < 3\n+        assert report.sources[0].result.error_kind is ErrorKind.TIMEOUT\n+        assert \"K\" not in env\n+\n+    def test_malformed_secrets_cfg_shapes_are_safe(self, tmp_path):\n+        reg.register_source(_make_source(secrets={\"K\": \"v\"}))\n+        for cfg in (None, [], \"junk\", {\"dummy\": \"not-a-dict\"}, {\"sources\": \"junk\"}):\n+            report = reg.apply_all(cfg, tmp_path, environ={})\n+            assert isinstance(report, reg.ApplyReport)\n+\n+    def test_unknown_sources_entry_warns_but_continues(self, tmp_path, caplog):\n+        reg.register_source(_make_source(secrets={\"K\": \"v\"}))\n+        env: dict = {}\n+        reg.apply_all(\n+            {\"sources\": [\"ghost\", \"dummy\"], \"dummy\": {\"enabled\": True}},\n+            tmp_path, environ=env,\n+        )\n+        assert env[\"K\"] == \"v\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Shared helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestHelpers:\n+    def test_is_valid_env_name(self):\n+        assert is_valid_env_name(\"GOOD_NAME\")\n+        assert is_valid_env_name(\"_LEADING\")\n+        assert not is_valid_env_name(\"\")\n+        assert not is_valid_env_name(\"1BAD\")\n+        assert not is_valid_env_name(\"bad-name\")\n+        assert not is_valid_env_name(\"has space\")\n+\n+    def test_scrub_ansi_removes_whole_sequences(self):\n+        assert scrub_ansi(\"\\x1b[31mred\\x1b[0m plain\") == \"red plain\"\n+        assert scrub_ansi(\"\\x1b]0;title\\x07text\") == \"text\"\n+        assert scrub_ansi(\"\") == \"\"\n+\n+    def test_run_secret_cli_minimal_env(self):\n+        proc = run_secret_cli(\n+            [sys.executable, \"-c\",\n+             \"import os, json; print(json.dumps(sorted(os.environ)))\"],\n+        )\n+        import json\n+\n+        child_env = json.loads(proc.stdout)\n+        # No credential-bearing vars from the parent env leak through.\n+        assert not any(k.endswith((\"_API_KEY\", \"_TOKEN\", \"_SECRET\"))\n+                       for k in child_env)\n+        assert \"NO_COLOR\" in child_env\n+\n+    def test_run_secret_cli_allowlist_passes_named_vars(self, monkeypatch):\n+        monkeypatch.setenv(\"MY_AUTH_TOKEN\", \"tok\")\n+        monkeypatch.setenv(\"OTHER_API_KEY\", \"leak\")\n+        proc = run_secret_cli(\n+            [sys.executable, \"-c\",\n+             \"import os; print(os.environ.get('MY_AUTH_TOKEN', '')); \"\n+             \"print(os.environ.get('OTHER_API_KEY', ''))\"],\n+            allow_env=[\"MY_AUTH_TOKEN\"],\n+        )\n+        lines = proc.stdout.splitlines()\n+        assert lines[0] == \"tok\"\n+        assert lines[1] == \"\"\n+\n+    def test_run_secret_cli_timeout_raises_runtime_error(self):\n+        with pytest.raises(RuntimeError, match=\"timed out\"):\n+            run_secret_cli(\n+                [sys.executable, \"-c\", \"import time; time.sleep(10)\"],\n+                timeout=0.3,\n+            )\n+\n+    def test_run_secret_cli_stdin_devnull(self):\n+        # A helper that tries to prompt reads EOF immediately.\n+        proc = run_secret_cli(\n+            [sys.executable, \"-c\",\n+             \"import sys; print(repr(sys.stdin.read()))\"],\n+        )\n+        assert proc.stdout.strip() == \"''\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Bitwarden adapter\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestBitwardenSource:\n+    def test_identity(self):\n+        src = BitwardenSource()\n+        assert src.name == \"bitwarden\"\n+        assert src.shape == \"bulk\"\n+        assert src.scheme == \"bws\"\n+\n+    def test_override_existing_defaults_true(self):\n+        src = BitwardenSource()\n+        assert src.override_existing({}) is True\n+        assert src.override_existing({\"override_existing\": False}) is False\n+\n+    def test_protected_vars_track_token_env(self):\n+        src = BitwardenSource()\n+        assert src.protected_env_vars({}) == frozenset({\"BWS_ACCESS_TOKEN\"})\n+        assert src.protected_env_vars(\n+            {\"access_token_env\": \"CUSTOM_TOKEN\"}\n+        ) == frozenset({\"CUSTOM_TOKEN\"})\n+\n+    def test_fetch_missing_token_not_configured(self, tmp_path, monkeypatch):\n+        monkeypatch.delenv(\"BWS_ACCESS_TOKEN\", raising=False)\n+        result = BitwardenSource().fetch({\"enabled\": True}, tmp_path)\n+        assert result.error_kind is ErrorKind.NOT_CONFIGURED\n+        assert \"BWS_ACCESS_TOKEN\" in result.error\n+\n+    def test_fetch_missing_project_not_configured(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"BWS_ACCESS_TOKEN\", \"0.token\")\n+        result = BitwardenSource().fetch({\"enabled\": True}, tmp_path)\n+        assert result.error_kind is ErrorKind.NOT_CONFIGURED\n+        assert \"project_id\" in result.error\n+\n+    def test_fetch_delegates_to_fetch_bitwarden_secrets(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"BWS_ACCESS_TOKEN\", \"0.token\")\n+        import agent.secret_sources.bitwarden as bw\n+\n+        monkeypatch.setattr(bw, \"find_bws\", lambda **kw: Path(\"/fake/bws\"))\n+        captured = {}\n+\n+        def _fake_fetch(**kwargs):\n+            captured.update(kwargs)\n+            return {\"MY_KEY\": \"val\"}, [\"a warning\"]\n+\n+        monkeypatch.setattr(bw, \"fetch_bitwarden_secrets\", _fake_fetch)\n+        result = BitwardenSource().fetch(\n+            {\"enabled\": True, \"project_id\": \"proj\",\n+             \"server_url\": \" https://vault.bitwarden.eu \"},\n+            tmp_path,\n+        )\n+        assert result.ok\n+        assert result.secrets == {\"MY_KEY\": \"val\"}\n+        assert result.warnings == [\"a warning\"]\n+        assert captured[\"project_id\"] == \"proj\"\n+        assert captured[\"server_url\"] == \"https://vault.bitwarden.eu\"\n+        assert captured[\"home_path\"] == tmp_path\n+\n+    def test_fetch_runtime_error_classified(self, tmp_path, monkeypatch):\n+        monkeypatch.setenv(\"BWS_ACCESS_TOKEN\", \"0.token\")\n+        import agent.secret_sources.bitwarden as bw\n+\n+        monkeypatch.setattr(bw, \"find_bws\", lambda **kw: Path(\"/fake/bws\"))\n+\n+        def _fail(**kwargs):\n+            raise RuntimeError(\"bws exited 1: 401 unauthorized\")\n+\n+        monkeypatch.setattr(bw, \"fetch_bitwarden_secrets\", _fail)\n+        result = BitwardenSource().fetch(\n+            {\"enabled\": True, \"project_id\": \"proj\"}, tmp_path\n+        )\n+        assert result.error_kind is ErrorKind.AUTH_FAILED\n+\n+    def test_e2e_through_orchestrator(self, tmp_path, monkeypatch):\n+        \"\"\"Full path: registry → BitwardenSource → env, with fetch mocked.\"\"\"\n+        monkeypatch.setenv(\"BWS_ACCESS_TOKEN\", \"0.token\")\n+        import agent.secret_sources.bitwarden as bw\n+\n+        monkeypatch.setattr(bw, \"find_bws\", lambda **kw: Path(\"/fake/bws\"))\n+        monkeypatch.setattr(\n+            bw, \"fetch_bitwarden_secrets\",\n+            lambda **kw: ({\"ANTHROPIC_API_KEY\": \"sk-ant\", \"BWS_ACCESS_TOKEN\": \"steal\"}, []),\n+        )\n+        reg.register_source(BitwardenSource())\n+        env = {\"BWS_ACCESS_TOKEN\": \"0.token\"}\n+        report = reg.apply_all(\n+            {\"bitwarden\": {\"enabled\": True, \"project_id\": \"proj\"}},\n+            tmp_path, environ=env,\n+        )\n+        assert env[\"ANTHROPIC_API_KEY\"] == \"sk-ant\"\n+        # The bootstrap token is protected even though BSM carried it.\n+        assert env[\"BWS_ACCESS_TOKEN\"] == \"0.token\"\n+        assert report.provenance[\"ANTHROPIC_API_KEY\"].source == \"bitwarden\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Conformance kit applied to the bundled source\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestBitwardenConformance(SecretSourceConformance):\n+    @pytest.fixture\n+    def source(self, monkeypatch):\n+        # Never hit the network / auto-install path in conformance runs.\n+        import agent.secret_sources.bitwarden as bw\n+\n+        monkeypatch.setattr(bw, \"find_bws\", lambda **kw: None)\n+        monkeypatch.delenv(\"BWS_ACCESS_TOKEN\", raising=False)\n+        return BitwardenSource()\n+\n+\n+# ---------------------------------------------------------------------------\n+# 1Password adapter\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestOnePasswordSource:\n+    def test_identity(self):\n+        from agent.secret_sources.onepassword import OnePasswordSource\n+\n+        src = OnePasswordSource()\n+        assert src.name == \"onepassword\"\n+        assert src.shape == \"mapped\"\n+        assert src.scheme == \"op\"\n+\n+    def test_override_existing_defaults_true(self):\n+        from agent.secret_sources.onepassword import OnePasswordSource\n+\n+        src = OnePasswordSource()\n+        assert src.override_existing({}) is True\n+        assert src.override_existing({\"override_existing\": False}) is False\n+\n+    def test_protected_vars_track_token_env(self):\n+        from agent.secret_sources.onepassword import OnePasswordSource\n+\n+        src = OnePasswordSource()\n+        assert src.protected_env_vars({}) == frozenset(\n+            {\"OP_SERVICE_ACCOUNT_TOKEN\"}\n+        )\n+        assert src.protected_env_vars(\n+            {\"service_account_token_env\": \"MY_OP_TOKEN\"}\n+        ) == frozenset({\"MY_OP_TOKEN\"})\n+\n+    def test_fetch_empty_map_not_configured(self, tmp_path):\n+        from agent.secret_sources.onepassword import OnePasswordSource\n+\n+        result = OnePasswordSource().fetch({\"enabled\": True}, tmp_path)\n+        assert result.error_kind is ErrorKind.NOT_CONFIGURED\n+\n+    def test_fetch_missing_binary(self, tmp_path, monkeypatch):\n+        import agent.secret_sources.onepassword as op\n+\n+        monkeypatch.setattr(op, \"find_op\", lambda *_a, **_kw: None)\n+        result = op.OnePasswordSource().fetch(\n+            {\"enabled\": True, \"env\": {\"K\": \"op://V/I/F\"}}, tmp_path\n+        )\n+        assert result.error_kind is ErrorKind.BINARY_MISSING\n+\n+    def test_fetch_delegates_and_passes_config(self, tmp_path, monkeypatch):\n+        import agent.secret_sources.onepassword as op\n+\n+        monkeypatch.setattr(op, \"find_op\", lambda *_a, **_kw: Path(\"/fake/op\"))\n+        captured = {}\n+\n+        def _fake_fetch(**kwargs):\n+            captured.update(kwargs)\n+            return {\"K\": \"v\"}, [\"warn\"]\n+\n+        monkeypatch.setattr(op, \"fetch_onepassword_secrets\", _fake_fetch)\n+        result = op.OnePasswordSource().fetch(\n+            {\"enabled\": True, \"env\": {\"K\": \"op://V/I/F\"},\n+             \"account\": \"team\", \"service_account_token_env\": \"MY_TOK\"},\n+            tmp_path,\n+        )\n+        assert result.ok and result.secrets == {\"K\": \"v\"}\n+        assert captured[\"references\"] == {\"K\": \"op://V/I/F\"}\n+        assert captured[\"account\"] == \"team\"\n+        assert captured[\"token_env\"] == \"MY_TOK\"\n+\n+    def test_invalid_refs_warned_not_fatal(self, tmp_path, monkeypatch):\n+        import agent.secret_sources.onepassword as op\n+\n+        monkeypatch.setattr(op, \"find_op\", lambda *_a, **_kw: Path(\"/fake/op\"))\n+        monkeypatch.setattr(op, \"fetch_onepassword_secrets\",\n+                            lambda **kw: ({\"GOOD\": \"v\"}, []))\n+        result = op.OnePasswordSource().fetch(\n+            {\"enabled\": True,\n+             \"env\": {\"GOOD\": \"op://V/I/F\", \"BAD\": \"not-a-ref\",\n+                     \"bad name\": \"op://V/I/F\"}},\n+            tmp_path,\n+        )\n+        assert result.ok\n+        assert len(result.warnings) == 2\n+\n+    def test_mapped_op_beats_bulk_bitwarden_through_orchestrator(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"The headline multi-source scenario: both vaults claim the same var.\"\"\"\n+        import agent.secret_sources.bitwarden as bw\n+        import agent.secret_sources.onepassword as op\n+\n+        monkeypatch.setenv(\"BWS_ACCESS_TOKEN\", \"0.token\")\n+        monkeypatch.setattr(bw, \"find_bws\", lambda **kw: Path(\"/fake/bws\"))\n+        monkeypatch.setattr(\n+            bw, \"fetch_bitwarden_secrets\",\n+            lambda **kw: ({\"SHARED_KEY\": \"from-bitwarden\",\n+                           \"BW_ONLY\": \"bw-val\"}, []),\n+        )\n+        monkeypatch.setattr(op, \"find_op\", lambda *_a, **_kw: Path(\"/fake/op\"))\n+        monkeypatch.setattr(\n+            op, \"fetch_onepassword_secrets\",\n+            lambda **kw: ({\"SHARED_KEY\": \"from-1password\"}, []),\n+        )\n+        reg.register_source(bw.BitwardenSource())\n+        reg.register_source(op.OnePasswordSource())\n+        env = {\"BWS_ACCESS_TOKEN\": \"0.token\"}\n+        report = reg.apply_all(\n+            {\n+                # bitwarden listed FIRST — mapped 1Password must still win.\n+                \"sources\": [\"bitwarden\", \"onepassword\"],\n+                \"bitwarden\": {\"enabled\": True, \"project_id\": \"proj\"},\n+                \"onepassword\": {\"enabled\": True,\n+                                \"env\": {\"SHARED_KEY\": \"op://V/I/F\"}},\n+            },\n+            tmp_path, environ=env,\n+        )\n+        assert env[\"SHARED_KEY\"] == \"from-1password\"\n+        assert env[\"BW_ONLY\"] == \"bw-val\"\n+        assert report.provenance[\"SHARED_KEY\"].source == \"onepassword\"\n+        assert report.provenance[\"BW_ONLY\"].source == \"bitwarden\"\n+        assert report.conflicts  # the shadowed bitwarden claim is surfaced\n+\n+\n+class TestOnePasswordConformance(SecretSourceConformance):\n+    @pytest.fixture\n+    def source(self, monkeypatch):\n+        import agent.secret_sources.onepassword as op\n+\n+        monkeypatch.setattr(op, \"find_op\", lambda *_a, **_kw: None)\n+        monkeypatch.delenv(\"OP_SERVICE_ACCOUNT_TOKEN\", raising=False)\n+        return op.OnePasswordSource()"]},{"file":"tests/test_bitwarden_secrets.py","hunks":["@@ -639,20 +639,23 @@ def test_env_loader_calls_bsm_when_enabled(tmp_path, monkeypatch):\n     monkeypatch.delenv(\"MY_BSM_KEY\", raising=False)\n \n     called = {\"n\": 0}\n-    def fake_apply(**kwargs):\n+\n+    def fake_fetch(**kwargs):\n         called[\"n\"] += 1\n-        assert kwargs[\"enabled\"] is True\n         assert kwargs[\"project_id\"] == \"proj-1\"\n-        os.environ[\"MY_BSM_KEY\"] = \"from-bsm\"\n-        return bw.FetchResult(\n-            secrets={\"MY_BSM_KEY\": \"from-bsm\"},\n-            applied=[\"MY_BSM_KEY\"],\n-        )\n+        return {\"MY_BSM_KEY\": \"from-bsm\"}, []\n \n     monkeypatch.setattr(\n-        \"agent.secret_sources.bitwarden.apply_bitwarden_secrets\",\n-        fake_apply,\n+        \"agent.secret_sources.bitwarden.find_bws\",\n+        lambda **_kw: Path(\"/fake/bws\"),\n     )\n+    monkeypatch.setattr(\n+        \"agent.secret_sources.bitwarden.fetch_bitwarden_secrets\",\n+        fake_fetch,\n+    )\n+    from agent.secret_sources import registry as reg_module\n+\n+    reg_module._reset_registry_for_tests()\n \n     from hermes_cli.env_loader import _apply_external_secret_sources\n     _apply_external_secret_sources(home)"]},{"file":"tests/test_env_loader_op_bootstrap.py","hunks":["@@ -0,0 +1,165 @@\n+\"\"\"Tests for the 1Password bootstrap-token reliability patches.\n+\n+Two behaviours are covered:\n+\n+1. ``load_hermes_dotenv()`` auto-loads ``~/.hermes/.op.env`` so the\n+   ``OP_SERVICE_ACCOUNT_TOKEN`` bootstrap token is available to\n+   ``apply_onepassword_secrets()`` in cron / subprocess / macOS / Docker\n+   contexts that inherit no shell state (no systemd EnvironmentFile, no\n+   ``op run``).  ``.op.env`` must never override a token already present\n+   in the environment (e.g. injected by a systemd ``EnvironmentFile``).\n+\n+2. ``credential_pool._seed_from_env`` (via the inner\n+   ``_get_env_prefer_dotenv``) must prefer an already-resolved value from\n+   ``os.environ`` over a raw ``op://`` reference still sitting in ``.env``,\n+   while leaving the normal ``.env``-takes-precedence behaviour untouched\n+   for every non-``op://`` value.\n+\n+These stay fully hermetic — the real ``op`` binary is never invoked and no\n+1Password integration is enabled.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+import sys\n+from pathlib import Path\n+from unittest import mock\n+\n+import pytest\n+\n+# Make the worktree importable without depending on the installed wheel.\n+ROOT = Path(__file__).resolve().parents[1]\n+if str(ROOT) not in sys.path:\n+    sys.path.insert(0, str(ROOT))\n+\n+from hermes_cli import env_loader  # noqa: E402\n+import agent.credential_pool as credential_pool  # noqa: E402\n+\n+\n[redacted:email](autouse=True)\n+def _isolate_op_token(monkeypatch):\n+    \"\"\"Each test starts with OP_SERVICE_ACCOUNT_TOKEN unset and a clean cache.\"\"\"\n+    monkeypatch.delenv(\"OP_SERVICE_ACCOUNT_TOKEN\", raising=False)\n+    env_loader.reset_secret_source_cache()\n+    yield\n+    env_loader.reset_secret_source_cache()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Patch 1 — .op.env bootstrap-token auto-load\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_op_env_autoloads_bootstrap_token_in_cron_context(tmp_path, monkeypatch):\n+    \"\"\"A fresh interpreter (no inherited shell state) picks up the token.\"\"\"\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    # .env carries user secrets / op:// references but NOT the bootstrap token.\n+    (home / \".env\").write_text(\"FOO=bar\\n\", encoding=\"utf-8\")\n+    # The gitignored .op.env holds only the service-account token.\n+    (home / \".op.env\").write_text(\n+        \"OP_SERVICE_ACCOUNT_TOKEN=[redacted:secret-assignment]\", encoding=\"utf-8\"\n+    )\n+\n+    assert os.environ.get(\"OP_SERVICE_ACCOUNT_TOKEN\") is None\n+\n+    env_loader.load_hermes_dotenv(hermes_home=home)\n+\n+    assert os.environ[\"OP_SERVICE_ACCOUNT_TOKEN\"] == \"test-token\"\n+\n+\n+def test_op_env_does_not_override_existing_token(tmp_path, monkeypatch):\n+    \"\"\"A token already in the environment (e.g. systemd EnvironmentFile) wins.\"\"\"\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    (home / \".env\").write_text(\"FOO=bar\\n\", encoding=\"utf-8\")\n+    (home / \".op.env\").write_text(\n+        \"OP_SERVICE_ACCOUNT_TOKEN=[redacted:secret-assignment]\", encoding=\"utf-8\"\n+    )\n+\n+    monkeypatch.setenv(\"OP_SERVICE_ACCOUNT_TOKEN\", \"live-token\")\n+\n+    env_loader.load_hermes_dotenv(hermes_home=home)\n+\n+    # override=False AND the explicit guard both protect the live token.\n+    assert os.environ[\"OP_SERVICE_ACCOUNT_TOKEN\"] == \"live-token\"\n+\n+\n+def test_missing_op_env_is_a_noop(tmp_path):\n+    \"\"\"No .op.env present must not raise and must not invent a token.\"\"\"\n+    home = tmp_path / \".hermes\"\n+    home.mkdir()\n+    (home / \".env\").write_text(\"FOO=bar\\n\", encoding=\"utf-8\")\n+\n+    env_loader.load_hermes_dotenv(hermes_home=home)\n+\n+    assert os.environ.get(\"OP_SERVICE_ACCOUNT_TOKEN\") is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# Patch 2 — credential_pool prefers resolved value over raw op:// ref\n+# ---------------------------------------------------------------------------\n+\n+\n+def _seed_openrouter_token(monkeypatch, dotenv_value, environ_value):\n+    \"\"\"Drive _seed_from_env('openrouter') and return the seeded access_token.\n+\n+    _get_env_prefer_dotenv is a closure inside _seed_from_env, so we exercise\n+    it through the openrouter seeding path, which calls\n+    _get_env_prefer_dotenv('OPENROUTER_API_KEY') and stores the result as the\n+    pooled credential's access_token.\n+    \"\"\"\n+    monkeypatch.setattr(\n+        credential_pool,\n+        \"load_env\",\n+        lambda: {\"OPENROUTER_API_KEY\": dotenv_value},\n+    )\n+    if environ_value is None:\n+        monkeypatch.delenv(\"OPENROUTER_API_KEY\", raising=False)\n+    else:\n+        monkeypatch.setenv(\"OPENROUTER_API_KEY\", environ_value)\n+    # Never treat the synthetic source as suppressed.\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth.is_source_suppressed\", lambda _p, _s: False\n+    )\n+\n+    entries: list = []\n+    changed, sources = credential_pool._seed_from_env(\"openrouter\", entries)\n+    assert changed and entries, \"expected a seeded openrouter credential\"\n+    return entries[0].access_token\n+\n+\n+def test_credential_pool_prefers_resolved_env_over_raw_op_ref(monkeypatch):\n+    \"\"\"A raw op:// reference in .env must lose to the resolved os.environ value.\"\"\"\n+    token = [redacted:secret-assignment]\n+        monkeypatch,\n+        dotenv_value=\"op://Vault/Item/field\",\n+        environ_value=\"resolved-value\",\n+    )\n+    assert token == \"resolved-value\"\n+\n+\n+def test_credential_pool_still_prefers_dotenv_for_non_op_values(monkeypatch):\n+    \"\"\"Regression guard: .env still beats os.environ for ordinary values.\"\"\"\n+    token = [redacted:secret-assignment]\n+        monkeypatch,\n+        dotenv_value=\"dotenv-value\",\n+        environ_value=\"shell-value\",\n+    )\n+    assert token == \"dotenv-value\"\n+\n+\n+def test_credential_pool_falls_back_to_env_when_dotenv_is_only_op_ref(monkeypatch):\n+    \"\"\"An unresolved op:// in .env with no resolved env value yields the raw ref.\n+\n+    This is the pre-resolution / misconfigured edge: there is nothing better\n+    to return, so behaviour is unchanged (the raw reference is surfaced rather\n+    than silently dropping the credential).\n+    \"\"\"\n+    token = [redacted:secret-assignment]\n+        monkeypatch,\n+        dotenv_value=\"op://Vault/Item/field\",\n+        environ_value=None,\n+    )\n+    assert token == \"op://Vault/Item/field\""]},{"file":"tests/test_env_loader_secret_sources.py","hunks":["@@ -63,11 +63,21 @@ def test_format_secret_source_suffix_generic_label_for_future_sources():\n     )\n \n \n+def test_format_secret_source_suffix_onepassword_uses_proper_name():\n+    env_loader._SECRET_SOURCES[\"OPENAI_API_KEY\"] = \"onepassword\"\n+    assert (\n+        env_loader.format_secret_source_suffix(\"OPENAI_API_KEY\")\n+        == \" (from 1Password)\"\n+    )\n+\n+\n def test_apply_external_secret_sources_records_bitwarden_origin(tmp_path, monkeypatch):\n-    \"\"\"End-to-end: when ``apply_bitwarden_secrets`` returns applied keys,\n-    they end up in ``_SECRET_SOURCES`` so the UI can label them.\"\"\"\n+    \"\"\"End-to-end: when the Bitwarden source fetches keys, applied vars\n+    end up in ``_SECRET_SOURCES`` so the UI can label them.\"\"\"\n \n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    monkeypatch.setenv(\"BWS_ACCESS_TOKEN\", \"0.test-token\")\n+    monkeypatch.delenv(\"ANTHROPIC_API_KEY\", raising=False)\n     config_path = tmp_path / \"config.yaml\"\n     config_path.write_text(\n         \"secrets:\\n\"","@@ -78,22 +88,19 @@ def test_apply_external_secret_sources_records_bitwarden_origin(tmp_path, monkey\n         encoding=\"utf-8\",\n     )\n \n-    # Stub apply_bitwarden_secrets to return a synthetic FetchResult.\n-    from agent.secret_sources.bitwarden import FetchResult\n+    # Stub the fetch layer under the SecretSource adapter.\n+    import agent.secret_sources.bitwarden as bw_module\n \n-    fake_result = FetchResult(\n-        secrets={\"ANTHROPIC_API_KEY\": \"sk-ant-test\"},\n-        applied=[\"ANTHROPIC_API_KEY\"],\n+    monkeypatch.setattr(bw_module, \"find_bws\", lambda **_kw: Path(\"/fake/bws\"))\n+    monkeypatch.setattr(\n+        bw_module,\n+        \"fetch_bitwarden_secrets\",\n+        lambda **_kw: ({\"ANTHROPIC_API_KEY\": \"sk-ant-test\"}, []),\n     )\n \n-    def _fake_apply(**_kwargs):\n-        return fake_result\n-\n-    # The import inside _apply_external_secret_sources is lazy, so we\n-    # patch the *module attribute* it will pull in.\n-    import agent.secret_sources.bitwarden as bw_module\n+    from agent.secret_sources import registry as reg_module\n \n-    monkeypatch.setattr(bw_module, \"apply_bitwarden_secrets\", _fake_apply)\n+    reg_module._reset_registry_for_tests()\n \n     env_loader._apply_external_secret_sources(tmp_path)\n ","@@ -131,6 +138,8 @@ def test_apply_external_secret_sources_dedupes_within_process(tmp_path, monkeypa\n     \"\"\"\n \n     monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    monkeypatch.setenv(\"BWS_ACCESS_TOKEN\", \"0.test-token\")\n+    monkeypatch.delenv(\"ANTHROPIC_API_KEY\", raising=False)\n     config_path = tmp_path / \"config.yaml\"\n     config_path.write_text(\n         \"secrets:\\n\"","@@ -141,19 +150,19 @@ def test_apply_external_secret_sources_dedupes_within_process(tmp_path, monkeypa\n         encoding=\"utf-8\",\n     )\n \n-    from agent.secret_sources.bitwarden import FetchResult\n-\n     call_count = {\"n\": 0}\n \n-    def _fake_apply(**_kwargs):\n+    def _fake_fetch(**_kwargs):\n         call_count[\"n\"] += 1\n-        return FetchResult(\n-            secrets={\"ANTHROPIC_API_KEY\": \"sk-ant-test\"},\n-            applied=[\"ANTHROPIC_API_KEY\"],\n-        )\n+        return {\"ANTHROPIC_API_KEY\": \"sk-ant-test\"}, []\n \n     import agent.secret_sources.bitwarden as bw_module\n-    monkeypatch.setattr(bw_module, \"apply_bitwarden_secrets\", _fake_apply)\n+    monkeypatch.setattr(bw_module, \"find_bws\", lambda **_kw: Path(\"/fake/bws\"))\n+    monkeypatch.setattr(bw_module, \"fetch_bitwarden_secrets\", _fake_fetch)\n+\n+    from agent.secret_sources import registry as reg_module\n+\n+    reg_module._reset_registry_for_tests()\n \n     # Five calls in a row, simulating module-import-time invocations from\n     # cli.py, hermes_cli/main.py, run_agent.py, trajectory_compressor.py,","@@ -173,3 +182,95 @@ def _fake_apply(**_kwargs):\n     env_loader.reset_secret_source_cache()\n     env_loader._apply_external_secret_sources(tmp_path)\n     assert call_count[\"n\"] == 2\n+\n+\n+def test_apply_external_secret_sources_records_onepassword_origin(tmp_path, monkeypatch):\n+    \"\"\"When the 1Password source resolves refs, applied vars end up in\n+    ``_SECRET_SOURCES`` labeled ``onepassword``.\"\"\"\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    monkeypatch.delenv(\"ANTHROPIC_API_KEY\", raising=False)\n+    (tmp_path / \"config.yaml\").write_text(\n+        \"secrets:\\n\"\n+        \"  onepassword:\\n\"\n+        \"    enabled: true\\n\"\n+        \"    env:\\n\"\n+        \"      ANTHROPIC_API_KEY: '[redacted:secret-assignment]'\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    import agent.secret_sources.onepassword as op_module\n+\n+    monkeypatch.setattr(op_module, \"find_op\", lambda *_a, **_kw: Path(\"/fake/op\"))\n+    monkeypatch.setattr(\n+        op_module,\n+        \"fetch_onepassword_secrets\",\n+        lambda **_kw: ({\"ANTHROPIC_API_KEY\": \"sk-ant-test\"}, []),\n+    )\n+\n+    from agent.secret_sources import registry as reg_module\n+\n+    reg_module._reset_registry_for_tests()\n+\n+    env_loader._apply_external_secret_sources(tmp_path)\n+\n+    assert env_loader.get_secret_source(\"ANTHROPIC_API_KEY\") == \"onepassword\"\n+    assert (\n+        env_loader.format_secret_source_suffix(\"ANTHROPIC_API_KEY\")\n+        == \" (from 1Password)\"\n+    )\n+\n+\n+def test_apply_external_secret_sources_survives_non_dict_section(tmp_path, monkeypatch):\n+    \"\"\"A malformed `secrets:` section must not abort startup (fail-open).\n+\n+    Both `onepassword: true` (non-dict) and a bad bitwarden section must be\n+    coerced to empty config instead of raising AttributeError up through\n+    load_hermes_dotenv().\n+    \"\"\"\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    (tmp_path / \"config.yaml\").write_text(\n+        \"secrets:\\n\"\n+        \"  bitwarden: true\\n\"\n+        \"  onepassword: [redacted:secret-assignment]\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    # Must not raise and must not record anything.\n+    env_loader._apply_external_secret_sources(tmp_path)\n+    assert env_loader.get_secret_source(\"ANYTHING\") is None\n+\n+\n+def test_apply_external_secret_sources_bad_ttl_does_not_crash(tmp_path, monkeypatch):\n+    \"\"\"A non-numeric cache_ttl_seconds must be coerced, not crash startup.\"\"\"\n+\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    (tmp_path / \"config.yaml\").write_text(\n+        \"secrets:\\n\"\n+        \"  onepassword:\\n\"\n+        \"    enabled: true\\n\"\n+        \"    cache_ttl_seconds: not-a-number\\n\"\n+        \"    env:\\n\"\n+        \"      K: 'op://V/I/F'\\n\",\n+        encoding=\"utf-8\",\n+    )\n+\n+    captured = {}\n+\n+    def _fake_fetch(**kwargs):\n+        captured.update(kwargs)\n+        return {}, []\n+\n+    import agent.secret_sources.onepassword as op_module\n+    monkeypatch.setattr(op_module, \"find_op\", lambda *_a, **_kw: Path(\"/fake/op\"))\n+    monkeypatch.setattr(op_module, \"fetch_onepassword_secrets\", _fake_fetch)\n+\n+    from agent.secret_sources import registry as reg_module\n+\n+    reg_module._reset_registry_for_tests()\n+\n+    env_loader._apply_external_secret_sources(tmp_path)\n+\n+    # Coerced to the 300s default rather than raising ValueError.\n+    assert captured[\"cache_ttl_seconds\"] == 300"]},{"file":"tests/test_onepassword_secrets.py","hunks":["@@ -0,0 +1,484 @@\n+\"\"\"Hermetic tests for the 1Password (`op` CLI) secret source.\n+\n+We never invoke the real ``op`` binary: ``subprocess.run`` is mocked so the\n+suite stays fast and offline-safe.  A live resolve is exercised manually via\n+``hermes secrets onepassword sync`` outside of pytest.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import os\n+import subprocess\n+import sys\n+import time\n+from pathlib import Path\n+from unittest import mock\n+\n+import pytest\n+\n+\n+# Make the worktree importable without depending on the installed wheel.\n+ROOT = Path(__file__).resolve().parents[1]\n+if str(ROOT) not in sys.path:\n+    sys.path.insert(0, str(ROOT))\n+\n+from agent.secret_sources import onepassword as op  # noqa: E402\n+\n+\n[redacted:email](autouse=True)\n+def _reset_caches():\n+    op._reset_cache_for_tests()\n+    yield\n+    op._reset_cache_for_tests()\n+\n+\n[redacted:email](autouse=True)\n+def _clean_op_env(monkeypatch):\n+    \"\"\"Start every test from a known 1Password auth state.\"\"\"\n+    for key in list(os.environ):\n+        if key.startswith(\"OP_SESSION_\"):\n+            monkeypatch.delenv(key, raising=False)\n+    monkeypatch.delenv(\"OP_SERVICE_ACCOUNT_TOKEN\", raising=False)\n+    monkeypatch.delenv(\"OP_ACCOUNT\", raising=False)\n+    yield\n+\n+\n+def _ok(value: str):\n+    return mock.Mock(returncode=0, stdout=value, stderr=\"\")\n+\n+\n+def _err(code: int, stderr: str):\n+    return mock.Mock(returncode=code, stdout=\"\", stderr=stderr)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Reference validation\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_validate_references_filters_bad_names_and_refs():\n+    refs = {\n+        \"OPENAI_API_KEY\": \"op://Private/OpenAI/api key\",\n+        \"1BAD_NAME\": \"op://Private/x/y\",          # bad env name\n+        \"HAS SPACE\": \"op://Private/x/y\",          # bad env name\n+        \"NOT_A_REF\": \"https://example.com\",        # not op://\n+        \"WHITESPACE\": \"  op://Private/z/field  \",  # stripped + kept\n+    }\n+    valid, warnings = op._validate_references(refs)\n+    assert valid == {\n+        \"OPENAI_API_KEY\": \"op://Private/OpenAI/api key\",\n+        \"WHITESPACE\": \"op://Private/z/field\",\n+    }\n+    assert len(warnings) == 3\n+\n+\n+# ---------------------------------------------------------------------------\n+# fetch_onepassword_secrets\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_fetch_happy_path(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    values = {\n+        \"op://Private/OpenAI/api key\": \"sk-abc\\n\",\n+        \"op://Private/Anthropic/credential\": \"sk-ant-xyz\",\n+    }\n+\n+    def fake_run(cmd, **kwargs):\n+        # argv list, never shell=True; reference passed after `--`.\n+        assert \"--\" in cmd\n+        ref = cmd[cmd.index(\"--\") + 1]\n+        return _ok(values[ref])\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+\n+    secrets, warnings = op.fetch_onepassword_secrets(\n+        references={\n+            \"OPENAI_API_KEY\": \"op://Private/OpenAI/api key\",\n+            \"ANTHROPIC_API_KEY\": \"op://Private/Anthropic/credential\",\n+        },\n+        binary=fake_op,\n+        use_cache=False,\n+    )\n+    assert secrets == {\"OPENAI_API_KEY\": \"sk-abc\", \"ANTHROPIC_API_KEY\": \"sk-ant-xyz\"}\n+    assert warnings == []\n+\n+\n+def test_fetch_uses_option_terminator_and_account(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    captured = {}\n+\n+    def fake_run(cmd, **kwargs):\n+        captured[\"cmd\"] = cmd\n+        return _ok(\"value\")\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+\n+    op.fetch_onepassword_secrets(\n+        references={\"K\": \"op://V/I/F\"},\n+        account=\"my.1password.com\",\n+        binary=fake_op,\n+        use_cache=False,\n+    )\n+    cmd = captured[\"cmd\"]\n+    assert cmd[:2] == [str(fake_op), \"read\"]\n+    assert \"--account\" in cmd and \"my.1password.com\" in cmd\n+    # `--` must precede the positional reference.\n+    assert cmd[-2:] == [\"--\", \"op://V/I/F\"]\n+\n+\n+def test_fetch_empty_rc0_does_not_clobber(monkeypatch, tmp_path):\n+    \"\"\"returncode 0 with empty stdout must surface as a warning, not a value.\"\"\"\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    monkeypatch.setattr(op.subprocess, \"run\", lambda *a, **k: _ok(\"   \\n\"))\n+\n+    secrets, warnings = op.fetch_onepassword_secrets(\n+        references={\"K\": \"op://V/I/F\"}, binary=fake_op, use_cache=False\n+    )\n+    assert secrets == {}\n+    assert any(\"empty value\" in w for w in warnings)\n+\n+\n+def test_fetch_read_failure_becomes_warning(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    monkeypatch.setattr(\n+        op.subprocess, \"run\", lambda *a, **k: _err(1, \"\\x1b[31m[ERROR] not signed in\\x1b[0m\")\n+    )\n+\n+    secrets, warnings = op.fetch_onepassword_secrets(\n+        references={\"K\": \"op://V/I/F\"}, binary=fake_op, use_cache=False\n+    )\n+    assert secrets == {}\n+    assert len(warnings) == 1\n+    # ANSI control sequences are fully scrubbed from the surfaced message.\n+    assert \"\\x1b\" not in warnings[0]\n+    assert \"[31m\" not in warnings[0]\n+    assert \"not signed in\" in warnings[0]\n+\n+\n+def test_fetch_one_bad_one_good(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+\n+    def fake_run(cmd, **kwargs):\n+        ref = cmd[cmd.index(\"--\") + 1]\n+        if ref == \"op://V/good/f\":\n+            return _ok(\"good-value\")\n+        return _err(1, \"no access\")\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+\n+    secrets, warnings = op.fetch_onepassword_secrets(\n+        references={\"GOOD\": \"op://V/good/f\", \"BAD\": \"op://V/bad/f\"},\n+        binary=fake_op,\n+        use_cache=False,\n+    )\n+    assert secrets == {\"GOOD\": \"good-value\"}\n+    assert len(warnings) == 1\n+\n+\n+def test_fetch_missing_binary_raises(monkeypatch):\n+    monkeypatch.setattr(op, \"find_op\", lambda binary_path=\"\": None)\n+    with pytest.raises(RuntimeError, match=\"op CLI not found\"):\n+        op.fetch_onepassword_secrets(\n+            references={\"K\": \"op://V/I/F\"}, use_cache=False\n+        )\n+\n+\n+def test_fetch_child_env_is_allowlisted(monkeypatch, tmp_path):\n+    \"\"\"The op child must NOT inherit unrelated provider credentials.\"\"\"\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    monkeypatch.setenv(\"OPENAI_API_KEY\", \"leak-me\")\n+    monkeypatch.setenv(\"OP_SERVICE_ACCOUNT_TOKEN\", \"ops_tok\")\n+    monkeypatch.setenv(\"OP_SESSION_myacct\", \"sess123\")\n+    captured = {}\n+\n+    def fake_run(cmd, **kwargs):\n+        captured[\"env\"] = kwargs[\"env\"]\n+        return _ok(\"v\")\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+    op.fetch_onepassword_secrets(\n+        references={\"K\": \"op://V/I/F\"}, binary=fake_op, use_cache=False\n+    )\n+    env = captured[\"env\"]\n+    assert \"OPENAI_API_KEY\" not in env          # not inherited\n+    assert env[\"OP_SERVICE_ACCOUNT_TOKEN\"] == \"ops_tok\"\n+    assert env[\"OP_SESSION_myacct\"] == \"sess123\"\n+    assert env.get(\"NO_COLOR\") == \"1\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Caching\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_inprocess_cache_hit(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    calls = {\"n\": 0}\n+\n+    def fake_run(*a, **k):\n+        calls[\"n\"] += 1\n+        return _ok(\"v\")\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+    op._reset_cache_for_tests(tmp_path)\n+    for _ in range(2):\n+        op.fetch_onepassword_secrets(\n+            references={\"K\": \"op://V/I/F\"}, cache_ttl_seconds=60,\n+            binary=fake_op, home_path=tmp_path,\n+        )\n+    assert calls[\"n\"] == 1  # second call served from L1 cache\n+\n+\n+def test_disk_cache_roundtrip_and_no_token_on_disk(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    monkeypatch.setenv(\"OP_SERVICE_ACCOUNT_TOKEN\", \"ops_supersecret\")\n+    calls = {\"n\": 0}\n+\n+    def fake_run(*a, **k):\n+        calls[\"n\"] += 1\n+        return _ok(\"resolved\")\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+    op._reset_cache_for_tests(tmp_path)\n+\n+    op.fetch_onepassword_secrets(\n+        references={\"K\": \"op://V/I/F\"}, cache_ttl_seconds=300,\n+        binary=fake_op, home_path=tmp_path,\n+    )\n+    assert calls[\"n\"] == 1\n+\n+    cache_path = op._disk_cache_path(tmp_path)\n+    assert cache_path.exists()\n+    assert (os.stat(cache_path).st_mode & 0o777) == 0o600\n+    text = cache_path.read_text()\n+    assert \"ops_supersecret\" not in text            # token never on disk\n+    payload = json.loads(text)\n+    assert payload[\"secrets\"] == {\"K\": \"resolved\"}\n+\n+    # Simulate a fresh process: clear only the in-process cache.\n+    op._CACHE.clear()\n+    op.fetch_onepassword_secrets(\n+        references={\"K\": \"op://V/I/F\"}, cache_ttl_seconds=300,\n+        binary=fake_op, home_path=tmp_path,\n+    )\n+    assert calls[\"n\"] == 1  # served from disk, op not re-invoked\n+\n+\n+def test_ttl_zero_disables_both_layers(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    calls = {\"n\": 0}\n+\n+    def fake_run(*a, **k):\n+        calls[\"n\"] += 1\n+        return _ok(\"v\")\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+    op._reset_cache_for_tests(tmp_path)\n+\n+    op.fetch_onepassword_secrets(\n+        references={\"K\": \"op://V/I/F\"}, cache_ttl_seconds=0,\n+        binary=fake_op, home_path=tmp_path,\n+    )\n+    # No disk file written when TTL is 0.\n+    assert not op._disk_cache_path(tmp_path).exists()\n+    op._CACHE.clear()\n+    op.fetch_onepassword_secrets(\n+        references={\"K\": \"op://V/I/F\"}, cache_ttl_seconds=0,\n+        binary=fake_op, home_path=tmp_path,\n+    )\n+    assert calls[\"n\"] == 2  # never cached\n+\n+\n+def test_session_change_invalidates_cache(monkeypatch, tmp_path):\n+    \"\"\"A different OP_SESSION_* identity must not reuse a cached value.\"\"\"\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    calls = {\"n\": 0}\n+\n+    def fake_run(*a, **k):\n+        calls[\"n\"] += 1\n+        return _ok(\"v\")\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+    op._reset_cache_for_tests(tmp_path)\n+\n+    monkeypatch.setenv(\"OP_SESSION_acctA\", \"sessA\")\n+    op.fetch_onepassword_secrets(\n+        references={\"K\": \"op://V/I/F\"}, cache_ttl_seconds=300,\n+        binary=fake_op, home_path=tmp_path,\n+    )\n+    # Switch identity.\n+    monkeypatch.delenv(\"OP_SESSION_acctA\", raising=False)\n+    monkeypatch.setenv(\"OP_SESSION_acctB\", \"sessB\")\n+    op._CACHE.clear()\n+    op.fetch_onepassword_secrets(\n+        references={\"K\": \"op://V/I/F\"}, cache_ttl_seconds=300,\n+        binary=fake_op, home_path=tmp_path,\n+    )\n+    assert calls[\"n\"] == 2  # cache key changed → refetch\n+\n+\n+def test_partial_failure_not_cached(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+\n+    def fake_run(cmd, **kwargs):\n+        ref = cmd[cmd.index(\"--\") + 1]\n+        return _ok(\"v\") if ref == \"op://V/good/f\" else _err(1, \"fail\")\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+    op._reset_cache_for_tests(tmp_path)\n+    op.fetch_onepassword_secrets(\n+        references={\"G\": \"op://V/good/f\", \"B\": \"op://V/bad/f\"},\n+        cache_ttl_seconds=300, binary=fake_op, home_path=tmp_path,\n+    )\n+    # A pull with any read error must not be persisted.\n+    assert not op._disk_cache_path(tmp_path).exists()\n+\n+\n+def test_reset_cache_clears_disk(tmp_path):\n+    cache_path = op._disk_cache_path(tmp_path)\n+    cache_path.parent.mkdir(parents=True, exist_ok=True)\n+    cache_path.write_text(\"{}\")\n+    assert cache_path.exists()\n+    op._reset_cache_for_tests(tmp_path)\n+    assert not cache_path.exists()\n+    op._reset_cache_for_tests(tmp_path)  # idempotent\n+\n+\n+# ---------------------------------------------------------------------------\n+# find_op\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_find_op_pinned_path_not_on_path(tmp_path, monkeypatch):\n+    pinned = tmp_path / \"op\"\n+    pinned.write_text(\"\")\n+    pinned.chmod(0o755)\n+    # PATH lookup must NOT be consulted when a binary_path is pinned.\n+    monkeypatch.setattr(op.shutil, \"which\", lambda name: \"/usr/bin/op\")\n+    assert op.find_op(str(pinned)) == pinned\n+\n+\n+def test_find_op_pinned_missing_returns_none(tmp_path, monkeypatch):\n+    monkeypatch.setattr(op.shutil, \"which\", lambda name: \"/usr/bin/op\")\n+    assert op.find_op(str(tmp_path / \"nope\")) is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# apply_onepassword_secrets\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_apply_disabled_returns_empty():\n+    result = op.apply_onepassword_secrets(enabled=False, env={\"K\": \"op://V/I/F\"})\n+    assert result.ok\n+    assert not result.applied\n+\n+\n+def test_apply_missing_binary_sets_error(monkeypatch):\n+    monkeypatch.setattr(op, \"find_op\", lambda binary_path=\"\": None)\n+    result = op.apply_onepassword_secrets(\n+        enabled=True, env={\"K\": \"op://V/I/F\"}\n+    )\n+    assert not result.ok\n+    assert \"op CLI\" in result.error\n+\n+\n+def test_apply_sets_env(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    monkeypatch.setattr(op, \"find_op\", lambda binary_path=\"\": fake_op)\n+    monkeypatch.setattr(op.subprocess, \"run\", lambda *a, **k: _ok(\"resolved-val\"))\n+    monkeypatch.delenv(\"MY_OP_KEY\", raising=False)\n+\n+    result = op.apply_onepassword_secrets(\n+        enabled=True, env={\"MY_OP_KEY\": \"op://V/I/F\"}, cache_ttl_seconds=0,\n+    )\n+    assert result.ok\n+    assert result.applied == [\"MY_OP_KEY\"]\n+    assert os.environ[\"MY_OP_KEY\"] == \"resolved-val\"\n+\n+\n+def test_apply_skips_before_fetch_when_not_overriding(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    monkeypatch.setattr(op, \"find_op\", lambda binary_path=\"\": fake_op)\n+    monkeypatch.setenv(\"MY_OP_KEY\", \"from-env\")\n+    calls = {\"n\": 0}\n+\n+    def fake_run(*a, **k):\n+        calls[\"n\"] += 1\n+        return _ok(\"from-1password\")\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+\n+    result = op.apply_onepassword_secrets(\n+        enabled=True, env={\"MY_OP_KEY\": \"op://V/I/F\"},\n+        override_existing=False, cache_ttl_seconds=0,\n+    )\n+    assert \"MY_OP_KEY\" in result.skipped\n+    assert os.environ[\"MY_OP_KEY\"] == \"from-env\"\n+    assert calls[\"n\"] == 0  # never even called op for a value we'd discard\n+\n+\n+def test_apply_never_overrides_token_var(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    monkeypatch.setattr(op, \"find_op\", lambda binary_path=\"\": fake_op)\n+    monkeypatch.setenv(\"OP_SERVICE_ACCOUNT_TOKEN\", \"original\")\n+    calls = {\"n\": 0}\n+\n+    def fake_run(*a, **k):\n+        calls[\"n\"] += 1\n+        return _ok(\"malicious\")\n+\n+    monkeypatch.setattr(op.subprocess, \"run\", fake_run)\n+\n+    result = op.apply_onepassword_secrets(\n+        enabled=True,\n+        env={\"OP_SERVICE_ACCOUNT_TOKEN\": \"op://V/I/F\"},\n+        override_existing=True, cache_ttl_seconds=0,\n+    )\n+    assert \"OP_SERVICE_ACCOUNT_TOKEN\" in result.skipped\n+    assert os.environ[\"OP_SERVICE_ACCOUNT_TOKEN\"] == \"original\"\n+    assert calls[\"n\"] == 0\n+\n+\n+def test_apply_never_raises_on_read_failure(monkeypatch, tmp_path):\n+    fake_op = tmp_path / \"op\"\n+    fake_op.write_text(\"\")\n+    monkeypatch.setattr(op, \"find_op\", lambda binary_path=\"\": fake_op)\n+    monkeypatch.setattr(op.subprocess, \"run\", lambda *a, **k: _err(1, \"locked\"))\n+    monkeypatch.delenv(\"MY_OP_KEY\", raising=False)\n+\n+    result = op.apply_onepassword_secrets(\n+        enabled=True, env={\"MY_OP_KEY\": \"op://V/I/F\"}, cache_ttl_seconds=0,\n+    )\n+    # Fail-open: warnings, nothing applied, no fatal error, no exception.\n+    assert result.ok\n+    assert result.applied == []\n+    assert result.warnings\n+\n+\n+def test_apply_no_valid_refs_is_noop(monkeypatch):\n+    # find_op must never be reached when there's nothing to fetch.\n+    monkeypatch.setattr(\n+        op, \"find_op\",\n+        lambda binary_path=\"\": (_ for _ in ()).throw(AssertionError(\"should not resolve op\")),\n+    )\n+    result = op.apply_onepassword_secrets(enabled=True, env={\"BAD NAME\": \"op://V/I/F\"})\n+    assert result.ok\n+    assert result.applied == []\n+    assert result.warnings  # the bad mapping warned"]},{"file":"website/docs/user-guide/secrets/index.md","hunks":["@@ -5,5 +5,30 @@ Hermes can pull API keys from external secret managers at process startup instea\n Supported:\n \n - [Bitwarden Secrets Manager](./bitwarden) — `bws` CLI, lazy-installed, free tier works.\n+- [1Password](./onepassword) — `op://` references via the official `op` CLI; service-account or desktop session auth.\n \n-More backends (Vault, AWS Secrets Manager, 1Password CLI) are easy to add behind the same interface — the lift is one module in `agent/secret_sources/` and one CLI handler. File a request if you have a specific one in mind.\n+## Multiple sources at once\n+\n+You can enable more than one secret source at the same time — for example a team Bitwarden project alongside a personal vault plugin. Sources compose per env var with a deterministic precedence ladder:\n+\n+1. **Your `.env` / shell wins by default.** A source only replaces a pre-existing value when its own `override_existing: true` is set (Bitwarden defaults to true so central rotation works).\n+2. **Mapped sources beat bulk sources.** A source where you explicitly bind env vars to references (an `env:` map) outranks a source that injects a whole project of secrets implicitly, regardless of ordering.\n+3. **First source wins.** Within the same shape, the order of the optional `secrets.sources` list (or registration order) decides. Later claims on an already-claimed var are skipped — with a startup warning, never silently.\n+\n+`override_existing` never lets one source overwrite a var another source already claimed, and no source can ever overwrite another source's bootstrap token (e.g. `BWS_ACCESS_TOKEN`).\n+\n+```yaml\n+secrets:\n+  sources: [bitwarden]     # optional explicit ordering\n+  bitwarden:\n+    enabled: true\n+    project_id: \"...\"\n+```\n+\n+Every credential injected by a source is labelled with its origin — setup flows and `hermes model` show `(from Bitwarden)` next to detected keys so you always know where a value came from.\n+\n+## Adding your own backend\n+\n+Third-party secret managers ship as standalone plugins, not core PRs. A backend subclasses `agent.secret_sources.base.SecretSource` (one required method: `fetch(cfg, home_path) -> FetchResult`) and registers via `ctx.register_secret_source(MySource())` in the plugin's `register(ctx)`. The orchestrator owns precedence, conflict handling, timeouts, and provenance — your source only fetches. Contract rules: `fetch()` never raises, never prompts, and returns within its timeout budget; validate your implementation against the conformance kit in `tests/secret_sources/conformance.py`.\n+\n+The bundled set is deliberately closed (same policy as memory providers): Bitwarden and 1Password ship in-tree. Everything else — Infisical, Proton Pass, HashiCorp Vault, AWS Secrets Manager, OS keystores — belongs in plugin repos; share them in the Nous Research Discord (`#plugins-skills-and-skins`)."]},{"file":"website/docs/user-guide/secrets/onepassword.md","hunks":["@@ -0,0 +1,166 @@\n+# 1Password\n+\n+Resolve provider API keys from [1Password](https://1password.com/) at process startup instead of storing them in plaintext inside `~/.hermes/.env`. You keep your keys as 1Password items and reference them by `op://vault/item/field`; rotating a credential becomes a single change in 1Password.\n+\n+## How it works\n+\n+1. You install the official [1Password CLI](https://developer.1password.com/docs/cli/get-started/) (`op`) and authenticate it — either with a **service-account token** (headless servers) or an **interactive/desktop session** (your laptop).\n+2. You map environment-variable names to `op://` references in `~/.hermes/config.yaml`.\n+3. Every time `hermes` (or the gateway, or a cron job) starts, after `~/.hermes/.env` has loaded, Hermes runs `op read` for each reference and sets the resolved values into `os.environ`.\n+4. By default Hermes **overrides** values already in your environment, so 1Password is the source of truth — rotate a credential once and every Hermes process picks it up on next start. Flip `override_existing: false` if you want `.env` to win instead.\n+\n+Hermes never authenticates on your behalf and never downloads `op`: it shells out to your already-installed, already-trusted CLI. If `op` is missing, your session is locked, or a reference is wrong, Hermes prints a one-line warning and continues with whatever credentials `.env` already had — it never blocks startup.\n+\n+## Authentication\n+\n+`op` supports two non-interactive-friendly modes; Hermes works with either:\n+\n+- **Service accounts** (recommended for servers/CI): create a service account in 1Password, grant it read access to the relevant vault, and export its token as `OP_SERVICE_ACCOUNT_TOKEN` in `~/.hermes/.env`. The token is the credential — treat it like any other bearer token.\n+- **Desktop / interactive sessions** (laptops): run `op signin` (or enable CLI integration in the 1Password app). Hermes passes your `OP_SESSION_*` variables through to the `op` child process. The 1Password cache key includes those session variables, so signing into a different account never serves a value cached under the previous identity.\n+\n+## Bootstrap token\n+\n+When you authenticate with a **service-account token**, that token is itself the bootstrap credential Hermes needs *before* it can resolve any `op://` reference. It must be present in `os.environ` of every process that resolves secrets — including cron jobs (`kanban.dispatch_in_gateway: false`), subprocess invocations, CLI runs, macOS launchd agents, and Docker containers — not just the interactive gateway. There are three ways to make it available, in order of precedence:\n+\n+1. **In `~/.hermes/.env` (recommended).** `hermes secrets onepassword setup --token <token>` writes the token to `~/.hermes/.env`, exactly like Bitwarden's `BWS_ACCESS_TOKEN`. Because `load_hermes_dotenv()` always loads `.env`, the token is available everywhere with zero extra setup. This is the simplest reliable option.\n+\n+2. **In `~/.hermes/.op.env` (gitignored).** If you'd rather keep the service-account token out of `.env` — for example so `.env` can be checked into a private dotfiles repo while the token stays out of version control — place it in `~/.hermes/.op.env`:\n+\n+   ```bash\n+   echo 'OP_SERVICE_ACCOUNT_TOKEN=[redacted:secret-assignment]' > ~/.hermes/.op.env\n+   chmod 600 ~/.hermes/.op.env\n+   ```\n+\n+   Hermes auto-loads `.op.env` at startup, **after** `.env`, and **never** overrides a token already present in the environment. `.op.env` is gitignored so the token never enters a committed file.\n+\n+3. **Via systemd `EnvironmentFile` (Linux gateway).** If you run the gateway under systemd, you can inject the token directly into the service environment:\n+\n+   ```ini\n+   [Service]\n+   EnvironmentFile=-/home/youruser/.hermes/.op.env\n+   ```\n+\n+   A token injected this way takes precedence — Hermes detects that `OP_SERVICE_ACCOUNT_TOKEN` is already set and skips loading `.op.env` entirely.\n+\n+If the token is reachable only through an interactive shell (`op signin`, `OP_SESSION_*` exports in `.bashrc`, etc.), it will **not** be inherited by cron jobs or freshly spawned subprocesses, and those contexts will log a warning and fall back to whatever credentials `.env` already held. Use one of the three options above for any non-interactive workload.\n+\n+## Setup\n+\n+### 1. Install and sign in to `op`\n+\n+Follow the [1Password CLI getting-started guide](https://developer.1password.com/docs/cli/get-started/). Verify it works:\n+\n+```bash\n+op whoami\n+```\n+\n+### 2. Enable the integration\n+\n+```bash\n+hermes secrets onepassword setup\n+```\n+\n+This verifies `op` is on `PATH` (or use `--binary-path`), records your account/token settings, checks for an active session, and flips `secrets.onepassword.enabled: true`. Non-interactive flags:\n+\n+```bash\n+hermes secrets onepassword setup \\\n+  --account my.1password.com \\\n+  --token-env OP_SERVICE_ACCOUNT_TOKEN \\\n+  --token \"$OP_SERVICE_ACCOUNT_TOKEN\"\n+```\n+\n+### 3. Map your credentials\n+\n+The reference format is `op://<vault>/<item>/<field>`:\n+\n+```bash\n+hermes secrets onepassword set OPENAI_API_KEY    \"op://Private/OpenAI/api key\"\n+hermes secrets onepassword set ANTHROPIC_API_KEY \"op://Private/Anthropic/credential\"\n+```\n+\n+### 4. Preview and confirm\n+\n+```bash\n+hermes secrets onepassword sync     # dry-run: resolve now, show what would apply\n+hermes secrets onepassword status   # config + binary + references + auth\n+```\n+\n+From now on, every `hermes` invocation resolves the references at startup. You'll see a one-line summary in stderr the first time secrets are applied in a process.\n+\n+## CLI\n+\n+| Command | What it does |\n+|---|---|\n+| `hermes secrets onepassword setup` | Verify `op`, set account / token env var, enable |\n+| `hermes secrets onepassword status` | Show config, binary, auth, and configured references |\n+| `hermes secrets onepassword set ENV_VAR \"op://…\"` | Map an env var to a reference (stored stripped + validated) |\n+| `hermes secrets onepassword remove ENV_VAR` | Drop a mapping |\n+| `hermes secrets onepassword sync` | Dry-run: resolve references now and show what would apply |\n+| `hermes secrets onepassword sync --apply` | Resolve and export into the current shell's environment |\n+| `hermes secrets onepassword disable` | Flip `enabled: false`; leaves mappings in place |\n+\n+`op` and `1password` are accepted as aliases for `onepassword`.\n+\n+## Configuration\n+\n+Defaults in `~/.hermes/config.yaml`:\n+\n+```yaml\n+secrets:\n+  onepassword:\n+    enabled: false\n+    env:\n+      OPENAI_API_KEY: \"[redacted:secret-assignment] key\"\n+      ANTHROPIC_API_KEY: \"[redacted:secret-assignment]\"\n+    account: \"\"\n+    service_account_token_env: [redacted:secret-assignment]\n+    binary_path: \"\"\n+    cache_ttl_seconds: 300\n+    override_existing: true\n+```\n+\n+| Key | Default | What it does |\n+|---|---|---|\n+| `enabled` | `false` | Master switch. When false, `op` is never invoked. |\n+| `env` | `{}` | Mapping of env-var name → `op://vault/item/field` reference. Entries whose name isn't a valid env-var name, or whose value isn't an `op://` reference, are skipped with a warning. |\n+| `account` | `\"\"` | Account shorthand / sign-in address passed as `op read --account`. Empty uses `op`'s default account. |\n+| `service_account_token_env` | `OP_SERVICE_ACCOUNT_TOKEN` | Env var Hermes reads the service-account token from. Its value is exported to the `op` child as `OP_SERVICE_ACCOUNT_TOKEN` (the name `op` expects). Leave the var unset to use a desktop/interactive session. |\n+| `binary_path` | `\"\"` | Absolute path to `op`. When set, it is used verbatim and `PATH` is **not** consulted — pin this to avoid trusting whatever `op` appears first on `PATH`. |\n+| `cache_ttl_seconds` | `300` | How long resolved values are reused (in-process and on disk). Set to `0` to disable **both** cache layers — no values are written to disk at all. |\n+| `override_existing` | `true` | When true, resolved values overwrite anything already in env (so rotation takes effect). Flip to `false` to let `.env` / shell exports win; those references are then skipped *before* `op` is invoked. |\n+\n+## Failure modes\n+\n+1Password never blocks Hermes startup. If anything goes wrong you'll see a one-line warning in stderr and Hermes continues:\n+\n+| Symptom | Cause | Fix |\n+|---|---|---|\n+| `the op CLI was not found on PATH` | `op` not installed / not on PATH | Install the CLI, or set `secrets.onepassword.binary_path` |\n+| `op read failed for 'op://…': …` | Locked session, expired token, or no vault access | `op signin`, refresh the token, or grant the service account access |\n+| `op read returned an empty value for 'op://…'` | The referenced field exists but is empty | Fix the item/field in 1Password (an empty value is never applied — your existing env var is left intact) |\n+| `… is not an op:// secret reference` | A mapping value isn't an `op://` reference | Re-set it with the correct `op://vault/item/field` form |\n+| `op read timed out` | Network blocked or 1Password slow | Check connectivity / the desktop app integration |\n+\n+## Caching\n+\n+Successful, complete pulls are cached in-process and on disk under `<hermes_home>/cache/op_cache.json` (written atomically, mode `0600`), so back-to-back short-lived `hermes` invocations don't re-shell `op` for every reference. The cache:\n+\n+- stores only resolved secret **values** — never the service-account token or any raw auth material (auth is fingerprinted into the cache key);\n+- is invalidated when the token, account, `OP_SESSION_*` variables, or the set of references change;\n+- is **not** written when a pull had any per-reference error, so a transient auth failure isn't frozen in for the TTL;\n+- is fully disabled — reads *and* writes — when `cache_ttl_seconds: 0`.\n+\n+## Security notes\n+\n+- A 1Password service-account token can read every secret the account has access to. Store it in `~/.hermes/.env` (not `config.yaml`), and revoke + regenerate from 1Password if it leaks.\n+- Hermes refuses to let a resolved value overwrite the token env var itself, even with `override_existing: true`.\n+- The `op` child process gets a minimal allowlisted environment (auth/session vars + `PATH`/`HOME`), not a copy of the full `os.environ`, so post-dotenv provider credentials aren't all inherited by the child.\n+- References are validated to start with `op://`, and the reference is passed after a `--` option terminator so a crafted value can't be parsed as an `op` flag.\n+\n+## When NOT to use this\n+\n+- **Single-machine personal setups** where `~/.hermes/.env` is fine.\n+- **Air-gapped environments** that can't reach 1Password.\n+- **CI/CD** where an existing secrets-injection mechanism is already wired up — pick one path, not two.\n+\n+The good case for this is multi-machine fleets, shared dev boxes, gateway VPSes, or anywhere you want centralized rotation and revocation across multiple Hermes installations.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}