diff --git a/agent/credential_persistence.py b/agent/credential_persistence.py new file mode 100644 index 00000000000..069384e7ce6 --- /dev/null +++ b/agent/credential_persistence.py @@ -0,0 +1,174 @@ +"""Credential-pool disk-boundary sanitization helpers. + +These helpers define which credential-pool entries are references to borrowed +runtime secrets and strip raw values before those entries are written to +``auth.json``. They intentionally have no dependency on ``hermes_cli.auth`` so +both the pool model and the final auth-store write boundary can share the same +policy without import cycles. +""" + +from __future__ import annotations + +import hashlib +import re +from typing import Any, Dict, Mapping + + +# Sources Hermes owns and can intentionally persist in auth.json. Everything +# else with a non-empty source is treated as borrowed/reference-only by default +# so future external secret providers fail closed at the disk boundary. +_PERSISTABLE_PROVIDER_SOURCES = frozenset({ + ("anthropic", "hermes_pkce"), + ("minimax-oauth", "oauth"), + ("nous", "device_code"), + ("openai-codex", "device_code"), + ("xai-oauth", "loopback_pkce"), +}) + +_SAFE_SECRETISH_METADATA_KEYS = frozenset({ + "secret_fingerprint", + "secret_source", + "token_type", + "scope", + "client_id", + "agent_key_id", + "agent_key_expires_at", + "agent_key_expires_in", + "agent_key_reused", + "agent_key_obtained_at", + "expires_at", + "expires_at_ms", + "expires_in", + "last_refresh", + "last_status", + "last_status_at", + "last_error_code", + "last_error_reason", + "last_error_message", + "last_error_reset_at", +}) + +_SECRET_VALUE_KEYS = frozenset({ + "access_token", + "refresh_token", + "agent_key", + "api_key", + "apikey", + "api_token", + "auth_token", + "authorization", + "bearer_token", + "client_secret", + "credential", + "credentials", + "id_token", + "oauth_token", + "private_key", + "secret_key", + "session_token", + "password", + "secret", + "token", + "tokens", +}) + +_SECRET_VALUE_SUFFIXES = ( + "_api_key", + "_api_token", + "_access_token", + "_auth_token", + "_refresh_token", + "_bearer_token", + "_client_secret", + "_id_token", + "_oauth_token", + "_private_key", + "_session_token", + "_secret_key", + "_password", + "_secret", + "_token", + "_key", +) + +_CAMEL_CASE_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])") + + +def _normalize_key(key: Any) -> str: + raw = str(key or "").strip() + raw = _CAMEL_CASE_BOUNDARY.sub("_", raw) + return raw.lower().replace("-", "_").replace(".", "_") + + +def is_borrowed_credential_source(source: Any, provider_id: Any = None) -> bool: + """Return True when ``source`` points at a borrowed/reference-only secret.""" + normalized_source = str(source or "").strip().lower() + if not normalized_source: + return False + if normalized_source == "manual" or normalized_source.startswith("manual:"): + return False + normalized_provider = str(provider_id or "").strip().lower() + return (normalized_provider, normalized_source) not in _PERSISTABLE_PROVIDER_SOURCES + + +def _is_secret_payload_key(key: Any) -> bool: + normalized = _normalize_key(key) + if not normalized or normalized in _SAFE_SECRETISH_METADATA_KEYS: + return False + if normalized in _SECRET_VALUE_KEYS: + return True + return normalized.endswith(_SECRET_VALUE_SUFFIXES) + + +def _fingerprint_value(value: Any) -> str | None: + if value is None: + return None + text = str(value) + if not text: + return None + digest = hashlib.sha256(text.encode("utf-8", errors="surrogatepass")).hexdigest() + return f"sha256:{digest[:16]}" + + +def _credential_secret_fingerprint(payload: Mapping[str, Any]) -> str | None: + for key in ("agent_key", "access_token", "refresh_token", "api_key", "token", "secret"): + fingerprint = _fingerprint_value(payload.get(key)) + if fingerprint: + return fingerprint + + for key, value in payload.items(): + if _is_secret_payload_key(key): + fingerprint = _fingerprint_value(value) + if fingerprint: + return fingerprint + + existing = payload.get("secret_fingerprint") + if isinstance(existing, str) and existing.startswith("sha256:"): + return existing + return None + + +def sanitize_borrowed_credential_payload( + payload: Mapping[str, Any], + provider_id: Any = None, +) -> Dict[str, Any]: + """Return a disk-safe credential-pool payload. + + Owned sources (manual entries and Hermes-owned OAuth/device-code state) + pass through unchanged. Borrowed/reference-only sources keep labels, + source refs, status/cooldown metadata, counters, and a non-reversible + fingerprint, but raw secret value fields are removed. + """ + result = dict(payload) + if not is_borrowed_credential_source(result.get("source"), provider_id): + return result + + fingerprint = _credential_secret_fingerprint(result) + sanitized = { + key: value + for key, value in result.items() + if not _is_secret_payload_key(key) + } + if fingerprint: + sanitized["secret_fingerprint"] = fingerprint + return sanitized diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 9a5cc20fe6f..8c2bfe44229 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -15,6 +15,10 @@ from typing import Any, Dict, List, Optional, Set, Tuple from hermes_constants import OPENROUTER_BASE_URL from hermes_cli.config import get_env_value, load_env +from agent.credential_persistence import ( + is_borrowed_credential_source, + sanitize_borrowed_credential_payload, +) import hermes_cli.auth as auth_mod from hermes_cli.auth import ( CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, @@ -86,7 +90,7 @@ CUSTOM_POOL_PREFIX = "custom:" _EXTRA_KEYS = frozenset({ "token_type", "scope", "client_id", "portal_base_url", "obtained_at", "expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused", - "agent_key_obtained_at", "tls", + "agent_key_obtained_at", "tls", "secret_source", "secret_fingerprint", }) @@ -161,7 +165,7 @@ class PooledCredential: for k, v in self.extra.items(): if v is not None: result[k] = v - return result + return sanitize_borrowed_credential_payload(result, self.provider) @property def runtime_api_key(self) -> str: @@ -1433,8 +1437,12 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p if field_updates or extra_updates: if extra_updates: field_updates["extra"] = {**existing.extra, **extra_updates} - entries[existing_idx] = replace(existing, **field_updates) - return True + updated = replace(existing, **field_updates) + entries[existing_idx] = updated + # Runtime-only borrowed secret updates should refresh the in-memory + # entry without forcing auth.json churn when the disk-safe payload is + # unchanged (for example env keys with the same fingerprint). + return existing.to_dict() != updated.to_dict() return False @@ -1772,6 +1780,35 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool except ImportError: def _is_source_suppressed(_p, _s): # type: ignore[misc] return False + + def _secret_source_for_env(env_var: str) -> Optional[str]: + try: + from hermes_cli.env_loader import get_secret_source + source_label = get_secret_source(env_var) + except Exception: + source_label = None + return str(source_label).strip() if source_label else None + + def _env_payload( + *, + source: str, + env_var: str, + token: str, + base_url: str, + auth_type: str = AUTH_TYPE_API_KEY, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "source": source, + "auth_type": auth_type, + "access_token": token, + "base_url": base_url, + "label": env_var, + } + secret_source = _secret_source_for_env(env_var) + if secret_source: + payload["secret_source"] = secret_source + return payload + if provider == "openrouter": # Prefer ~/.hermes/.env over os.environ token = _get_env_prefer_dotenv("OPENROUTER_API_KEY") @@ -1784,13 +1821,12 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool entries, provider, source, - { - "source": source, - "auth_type": AUTH_TYPE_API_KEY, - "access_token": token, - "base_url": OPENROUTER_BASE_URL, - "label": "OPENROUTER_API_KEY", - }, + _env_payload( + source=source, + env_var="OPENROUTER_API_KEY", + token=token, + base_url=OPENROUTER_BASE_URL, + ), ) return changed, active_sources @@ -1829,13 +1865,13 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool entries, provider, source, - { - "source": source, - "auth_type": auth_type, - "access_token": token, - "base_url": base_url, - "label": env_var, - }, + _env_payload( + source=source, + env_var=env_var, + token=token, + base_url=base_url, + auth_type=auth_type, + ), ) return changed, active_sources @@ -1847,8 +1883,11 @@ def _prune_stale_seeded_entries(entries: List[PooledCredential], active_sources: if _is_manual_source(entry.source) or entry.source in active_sources or not ( - entry.source.startswith("env:") - or entry.source in {"claude_code", "hermes_pkce"} + is_borrowed_credential_source(entry.source, entry.provider) + # Hermes PKCE is Hermes-owned/persistable while present, but it is + # still a file-backed singleton and should disappear from the pool + # when the backing OAuth file is gone. + or entry.source == "hermes_pkce" ) ] if len(retained) == len(entries): @@ -1933,17 +1972,22 @@ def _seed_custom_pool(pool_key: str, entries: List[PooledCredential]) -> Tuple[b def load_pool(provider: str) -> CredentialPool: provider = (provider or "").strip().lower() raw_entries = read_credential_pool(provider) + raw_needs_sanitization = any( + isinstance(payload, dict) + and sanitize_borrowed_credential_payload(payload, provider) != payload + for payload in raw_entries + ) entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries] if provider.startswith(CUSTOM_POOL_PREFIX): # Custom endpoint pool — seed from custom_providers config and model config custom_changed, custom_sources = _seed_custom_pool(provider, entries) - changed = custom_changed + changed = raw_needs_sanitization or custom_changed changed |= _prune_stale_seeded_entries(entries, custom_sources) else: singleton_changed, singleton_sources = _seed_from_singletons(provider, entries) env_changed, env_sources = _seed_from_env(provider, entries) - changed = singleton_changed or env_changed + changed = raw_needs_sanitization or singleton_changed or env_changed changed |= _prune_stale_seeded_entries(entries, singleton_sources | env_sources) changed |= _normalize_pool_priorities(provider, entries) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 57bed0c9e26..f087062b006 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -49,6 +49,7 @@ import yaml from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config from hermes_constants import OPENROUTER_BASE_URL, secure_parent_dir +from agent.credential_persistence import sanitize_borrowed_credential_payload from utils import atomic_replace, atomic_yaml_write, is_truthy_value logger = logging.getLogger(__name__) @@ -1168,14 +1169,23 @@ def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]: def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path: - """Persist one provider's credential pool under auth.json.""" + """Persist one provider's credential pool under auth.json. + + This is the final disk-boundary guard for borrowed/reference-only + credentials. Callers may pass raw dictionaries, so sanitize here even when + ``PooledCredential.to_dict()`` already did the same work upstream. + """ with _auth_store_lock(): auth_store = _load_auth_store() pool = auth_store.get("credential_pool") if not isinstance(pool, dict): pool = {} auth_store["credential_pool"] = pool - pool[provider_id] = list(entries) + pool[provider_id] = [ + sanitize_borrowed_credential_payload(entry, provider_id) + if isinstance(entry, dict) else entry + for entry in entries + ] return _save_auth_store(auth_store) diff --git a/hermes_cli/env_loader.py b/hermes_cli/env_loader.py index 40a87830dfe..447595c56d7 100644 --- a/hermes_cli/env_loader.py +++ b/hermes_cli/env_loader.py @@ -36,7 +36,9 @@ def get_secret_source(env_var: str) -> str | None: Returns ``"bitwarden"`` for keys pulled from Bitwarden Secrets Manager during the current process's ``load_hermes_dotenv()`` call. Returns ``None`` for keys that came from ``.env``, the shell environment, or - aren't tracked. + aren't tracked. The returned label is metadata only: credential-pool + persistence may store it to explain the origin of a borrowed secret, but + must never treat it as authorization to persist the raw value. """ return _SECRET_SOURCES.get(env_var) diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index bcb1ed595dd..d7fec49aaac 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -395,6 +395,324 @@ def test_load_pool_seeds_env_api_key(tmp_path, monkeypatch): +def test_load_pool_does_not_persist_env_seeded_secret_value(tmp_path, monkeypatch): + """Runtime env keys may be used in memory but must not land in auth.json.""" + sentinel = "S3NTINEL_DO_NOT_PERSIST_OPENROUTER" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("OPENROUTER_API_KEY", sentinel) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + + from agent.credential_pool import load_pool + + pool = load_pool("openrouter") + entry = pool.select() + + assert entry is not None + assert entry.source == "env:OPENROUTER_API_KEY" + assert entry.access_token == sentinel + + auth_text = (tmp_path / "hermes" / "auth.json").read_text() + assert sentinel not in auth_text + persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0] + assert persisted["source"] == "env:OPENROUTER_API_KEY" + assert persisted["label"] == "OPENROUTER_API_KEY" + assert persisted["auth_type"] == "api_key" + assert persisted["priority"] == 0 + assert "access_token" not in persisted + assert persisted["secret_fingerprint"].startswith("sha256:") + + + +def test_load_pool_persists_bitwarden_origin_metadata_without_secret(tmp_path, monkeypatch): + """Bitwarden-injected env vars retain source metadata but not raw values.""" + sentinel = "S3NTINEL_DO_NOT_PERSIST_BITWARDEN" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("OPENROUTER_API_KEY", sentinel) + monkeypatch.setattr( + "hermes_cli.env_loader.get_secret_source", + lambda env_var: "bitwarden" if env_var == "OPENROUTER_API_KEY" else None, + ) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + + from agent.credential_pool import load_pool + + pool = load_pool("openrouter") + entry = pool.select() + + assert entry is not None + assert entry.access_token == sentinel + assert entry.source == "env:OPENROUTER_API_KEY" + + auth_text = (tmp_path / "hermes" / "auth.json").read_text() + assert sentinel not in auth_text + persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0] + assert persisted["source"] == "env:OPENROUTER_API_KEY" + assert persisted["secret_source"] == "bitwarden" + assert "access_token" not in persisted + + + +def test_load_pool_sanitizes_legacy_raw_borrowed_entry_when_value_unchanged(tmp_path, monkeypatch): + """Existing raw env-seeded pool entries are rewritten even if the env value matches.""" + sentinel = "S3NTINEL_DO_NOT_PERSIST_LEGACY_RAW" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + monkeypatch.setenv("OPENROUTER_API_KEY", sentinel) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openrouter": [ + { + "id": "legacy-env", + "label": "OPENROUTER_API_KEY", + "auth_type": "api_key", + "priority": 0, + "source": "env:OPENROUTER_API_KEY", + "access_token": sentinel, + "base_url": "https://openrouter.ai/api/v1", + } + ] + }, + }, + ) + + from agent.credential_pool import load_pool + + pool = load_pool("openrouter") + entry = pool.select() + + assert entry is not None + assert entry.access_token == sentinel + auth_text = (tmp_path / "hermes" / "auth.json").read_text() + assert sentinel not in auth_text + persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0] + assert persisted["id"] == "legacy-env" + assert "access_token" not in persisted + assert persisted["secret_fingerprint"].startswith("sha256:") + + + +def test_pooled_credential_to_dict_strips_borrowed_secret_fields(): + from agent.credential_pool import PooledCredential + + sentinel = "S3NTINEL_DO_NOT_PERSIST_TO_DICT" + credential = PooledCredential( + provider="openrouter", + id="borrowed-1", + label="vault-ref", + auth_type="api_key", + priority=3, + source="vault:openrouter/api-key", + access_token=sentinel, + refresh_token=f"refresh-{sentinel}", + agent_key=f"agent-{sentinel}", + request_count=7, + last_status="ok", + extra={ + "api_key": f"extra-{sentinel}", + "client_secret": f"client-{sentinel}", + "secret_key": f"secret-key-{sentinel}", + "authToken": f"auth-token-{sentinel}", + "refreshToken": f"camel-refresh-{sentinel}", + "authorization": f"Bearer {sentinel}", + "tokens": {"access_token": f"nested-{sentinel}"}, + "token_type": "Bearer", + "scope": "inference", + }, + ) + + payload = credential.to_dict() + serialized = json.dumps(payload) + + assert sentinel not in serialized + assert "access_token" not in payload + assert "refresh_token" not in payload + assert "agent_key" not in payload + assert "api_key" not in payload + assert "client_secret" not in payload + assert "secret_key" not in payload + assert "authToken" not in payload + assert "refreshToken" not in payload + assert "authorization" not in payload + assert "tokens" not in payload + assert payload["source"] == "vault:openrouter/api-key" + assert payload["label"] == "vault-ref" + assert payload["request_count"] == 7 + assert payload["token_type"] == "Bearer" + assert payload["scope"] == "inference" + assert payload["secret_fingerprint"].startswith("sha256:") + + + +@pytest.mark.parametrize("source", [ + "age://openrouter/api-key", + "systemd", + "keyring", + "1password", + "pass", + "sops", + "future_secret_store:openrouter", +]) +def test_borrowed_source_variants_strip_secret_fields(source): + from agent.credential_pool import PooledCredential + + sentinel = f"S3NTINEL_DO_NOT_PERSIST_{source.replace(':', '_').replace('/', '_')}" + credential = PooledCredential( + provider="openrouter", + id="borrowed-variant", + label="borrowed", + auth_type="api_key", + priority=0, + source=source, + access_token=sentinel, + refresh_token=f"refresh-{sentinel}", + ) + + payload = credential.to_dict() + serialized = json.dumps(payload) + + assert sentinel not in serialized + assert "access_token" not in payload + assert "refresh_token" not in payload + assert payload["source"] == source + assert payload["secret_fingerprint"].startswith("sha256:") + + + +def test_load_pool_prunes_stale_borrowed_custom_config_entry(tmp_path, monkeypatch): + sentinel = "S3NTINEL_DO_NOT_PERSIST_STALE_CUSTOM" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "custom:foo": [ + { + "id": "stale-custom", + "label": "Foo", + "auth_type": "api_key", + "priority": 0, + "source": "config:Foo", + "access_token": sentinel, + "base_url": "https://foo.example/v1", + } + ] + }, + }, + ) + + from agent.credential_pool import load_pool + + pool = load_pool("custom:foo") + + assert pool.entries() == [] + auth_text = (tmp_path / "hermes" / "auth.json").read_text() + assert sentinel not in auth_text + assert json.loads(auth_text)["credential_pool"]["custom:foo"] == [] + + + +def test_write_credential_pool_sanitizes_borrowed_payload_at_disk_boundary(tmp_path, monkeypatch): + """Direct dictionary callers cannot bypass the borrowed-secret guard.""" + sentinel = "S3NTINEL_DO_NOT_PERSIST_DIRECT_WRITE" + manual_secret = "MANUAL_SECRET_STAYS_PERSISTABLE" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + + from hermes_cli.auth import write_credential_pool + + write_credential_pool("openrouter", [ + { + "id": "borrowed-1", + "label": "systemd-ref", + "auth_type": "api_key", + "priority": 0, + "source": "systemd://hermes/openrouter", + "access_token": sentinel, + "refresh_token": f"refresh-{sentinel}", + "agent_key": f"agent-{sentinel}", + "api_key": f"extra-{sentinel}", + }, + { + "id": "manual-1", + "label": "manual", + "auth_type": "api_key", + "priority": 1, + "source": "manual", + "access_token": manual_secret, + }, + ]) + + auth_text = (tmp_path / "hermes" / "auth.json").read_text() + assert sentinel not in auth_text + assert manual_secret in auth_text + entries = json.loads(auth_text)["credential_pool"]["openrouter"] + borrowed, manual = entries + assert borrowed["source"] == "systemd://hermes/openrouter" + assert "access_token" not in borrowed + assert "refresh_token" not in borrowed + assert "agent_key" not in borrowed + assert "api_key" not in borrowed + assert borrowed["secret_fingerprint"].startswith("sha256:") + assert manual["access_token"] == manual_secret + + + +def test_write_credential_pool_treats_unowned_oauth_source_as_borrowed(tmp_path, monkeypatch): + sentinel = "S3NTINEL_DO_NOT_PERSIST_UNOWNED_OAUTH" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + + from hermes_cli.auth import write_credential_pool + + write_credential_pool("openrouter", [ + { + "id": "unowned-oauth", + "label": "unowned-oauth", + "auth_type": "oauth", + "priority": 0, + "source": "oauth", + "access_token": sentinel, + "refresh_token": f"refresh-{sentinel}", + } + ]) + + auth_text = (tmp_path / "hermes" / "auth.json").read_text() + assert sentinel not in auth_text + persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0] + assert persisted["source"] == "oauth" + assert "access_token" not in persisted + assert "refresh_token" not in persisted + assert persisted["secret_fingerprint"].startswith("sha256:") + + + +def test_write_credential_pool_preserves_known_provider_owned_oauth_state(tmp_path, monkeypatch): + sentinel = "PROVIDER_OWNED_DEVICE_CODE_STAYS_PERSISTABLE" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + + from hermes_cli.auth import write_credential_pool + + write_credential_pool("nous", [ + { + "id": "nous-device", + "label": "device-code", + "auth_type": "oauth", + "priority": 0, + "source": "device_code", + "access_token": sentinel, + "refresh_token": f"refresh-{sentinel}", + "agent_key": f"agent-{sentinel}", + } + ]) + + persisted = json.loads((tmp_path / "hermes" / "auth.json").read_text())["credential_pool"]["nous"][0] + assert persisted["access_token"] == sentinel + assert persisted["refresh_token"] == f"refresh-{sentinel}" + assert persisted["agent_key"] == f"agent-{sentinel}" + + + def test_load_pool_prefers_dotenv_over_stale_os_environ(tmp_path, monkeypatch): """Regression for #18254: stale OPENROUTER_API_KEY in os.environ (inherited from a parent shell) must NOT shadow the fresh key in ~/.hermes/.env when diff --git a/website/docs/user-guide/features/credential-pools.md b/website/docs/user-guide/features/credential-pools.md index 49fb29c4ae7..508feee5b69 100644 --- a/website/docs/user-guide/features/credential-pools.md +++ b/website/docs/user-guide/features/credential-pools.md @@ -179,6 +179,8 @@ Hermes automatically discovers credentials from multiple sources and seeds the p Auto-seeded entries are updated on each pool load — if you remove an env var, its pool entry is automatically pruned. Manual entries (added via `hermes auth add`) are never auto-pruned. +Borrowed runtime secrets (for example env vars, Bitwarden/Vault/keyring/systemd references, and custom config values) are reference-only at the `auth.json` boundary. Hermes can use the resolved value in memory for the current run, but it persists only metadata such as the source ref, label, status, request counters, and a non-reversible fingerprint. Manual entries and Hermes-owned OAuth/device-code state keep the durable tokens they need to refresh. + ## Delegation & Subagent Sharing When the agent spawns subagents via `delegate_task`, the parent's credential pool is automatically shared with children: @@ -219,15 +221,28 @@ Pool state is stored in `~/.hermes/auth.json` under the `credential_pool` key: "auth_type": "api_key", "priority": 0, "source": "env:OPENROUTER_API_KEY", - "access_token": "sk-or-v1-...", + "secret_source": "bitwarden", + "secret_fingerprint": "sha256:12ab34cd56ef7890", "last_status": "ok", "request_count": 142 } + ], + "anthropic": [ + { + "id": "manual1", + "label": "personal-api-key", + "auth_type": "api_key", + "priority": 0, + "source": "manual", + "access_token": "sk-ant-api03-..." + } ] - }, + } } ``` +The OpenRouter entry above was borrowed from an external source, so the raw key is not stored in `auth.json`. The manual Anthropic entry was intentionally added to Hermes' credential store, so its token remains persistable. + Strategies are stored in `config.yaml` (not `auth.json`): ```yaml