fix: avoid persisting borrowed credential secrets (#31416)
This commit is contained in:
174
agent/credential_persistence.py
Normal file
174
agent/credential_persistence.py
Normal file
@ -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
|
||||||
@ -15,6 +15,10 @@ from typing import Any, Dict, List, Optional, Set, Tuple
|
|||||||
|
|
||||||
from hermes_constants import OPENROUTER_BASE_URL
|
from hermes_constants import OPENROUTER_BASE_URL
|
||||||
from hermes_cli.config import get_env_value, load_env
|
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
|
import hermes_cli.auth as auth_mod
|
||||||
from hermes_cli.auth import (
|
from hermes_cli.auth import (
|
||||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||||
@ -86,7 +90,7 @@ CUSTOM_POOL_PREFIX = "custom:"
|
|||||||
_EXTRA_KEYS = frozenset({
|
_EXTRA_KEYS = frozenset({
|
||||||
"token_type", "scope", "client_id", "portal_base_url", "obtained_at",
|
"token_type", "scope", "client_id", "portal_base_url", "obtained_at",
|
||||||
"expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused",
|
"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():
|
for k, v in self.extra.items():
|
||||||
if v is not None:
|
if v is not None:
|
||||||
result[k] = v
|
result[k] = v
|
||||||
return result
|
return sanitize_borrowed_credential_payload(result, self.provider)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def runtime_api_key(self) -> str:
|
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 field_updates or extra_updates:
|
||||||
if extra_updates:
|
if extra_updates:
|
||||||
field_updates["extra"] = {**existing.extra, **extra_updates}
|
field_updates["extra"] = {**existing.extra, **extra_updates}
|
||||||
entries[existing_idx] = replace(existing, **field_updates)
|
updated = replace(existing, **field_updates)
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
@ -1772,6 +1780,35 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
def _is_source_suppressed(_p, _s): # type: ignore[misc]
|
def _is_source_suppressed(_p, _s): # type: ignore[misc]
|
||||||
return False
|
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":
|
if provider == "openrouter":
|
||||||
# Prefer ~/.hermes/.env over os.environ
|
# Prefer ~/.hermes/.env over os.environ
|
||||||
token = _get_env_prefer_dotenv("OPENROUTER_API_KEY")
|
token = _get_env_prefer_dotenv("OPENROUTER_API_KEY")
|
||||||
@ -1784,13 +1821,12 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
|||||||
entries,
|
entries,
|
||||||
provider,
|
provider,
|
||||||
source,
|
source,
|
||||||
{
|
_env_payload(
|
||||||
"source": source,
|
source=source,
|
||||||
"auth_type": AUTH_TYPE_API_KEY,
|
env_var="OPENROUTER_API_KEY",
|
||||||
"access_token": token,
|
token=token,
|
||||||
"base_url": OPENROUTER_BASE_URL,
|
base_url=OPENROUTER_BASE_URL,
|
||||||
"label": "OPENROUTER_API_KEY",
|
),
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return changed, active_sources
|
return changed, active_sources
|
||||||
|
|
||||||
@ -1829,13 +1865,13 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
|||||||
entries,
|
entries,
|
||||||
provider,
|
provider,
|
||||||
source,
|
source,
|
||||||
{
|
_env_payload(
|
||||||
"source": source,
|
source=source,
|
||||||
"auth_type": auth_type,
|
env_var=env_var,
|
||||||
"access_token": token,
|
token=token,
|
||||||
"base_url": base_url,
|
base_url=base_url,
|
||||||
"label": env_var,
|
auth_type=auth_type,
|
||||||
},
|
),
|
||||||
)
|
)
|
||||||
return changed, active_sources
|
return changed, active_sources
|
||||||
|
|
||||||
@ -1847,8 +1883,11 @@ def _prune_stale_seeded_entries(entries: List[PooledCredential], active_sources:
|
|||||||
if _is_manual_source(entry.source)
|
if _is_manual_source(entry.source)
|
||||||
or entry.source in active_sources
|
or entry.source in active_sources
|
||||||
or not (
|
or not (
|
||||||
entry.source.startswith("env:")
|
is_borrowed_credential_source(entry.source, entry.provider)
|
||||||
or entry.source in {"claude_code", "hermes_pkce"}
|
# 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):
|
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:
|
def load_pool(provider: str) -> CredentialPool:
|
||||||
provider = (provider or "").strip().lower()
|
provider = (provider or "").strip().lower()
|
||||||
raw_entries = read_credential_pool(provider)
|
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]
|
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
|
||||||
|
|
||||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||||
# Custom endpoint pool — seed from custom_providers config and model config
|
# Custom endpoint pool — seed from custom_providers config and model config
|
||||||
custom_changed, custom_sources = _seed_custom_pool(provider, entries)
|
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)
|
changed |= _prune_stale_seeded_entries(entries, custom_sources)
|
||||||
else:
|
else:
|
||||||
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
|
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
|
||||||
env_changed, env_sources = _seed_from_env(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 |= _prune_stale_seeded_entries(entries, singleton_sources | env_sources)
|
||||||
changed |= _normalize_pool_priorities(provider, entries)
|
changed |= _normalize_pool_priorities(provider, entries)
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,7 @@ import yaml
|
|||||||
|
|
||||||
from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
|
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 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
|
from utils import atomic_replace, atomic_yaml_write, is_truthy_value
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
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():
|
with _auth_store_lock():
|
||||||
auth_store = _load_auth_store()
|
auth_store = _load_auth_store()
|
||||||
pool = auth_store.get("credential_pool")
|
pool = auth_store.get("credential_pool")
|
||||||
if not isinstance(pool, dict):
|
if not isinstance(pool, dict):
|
||||||
pool = {}
|
pool = {}
|
||||||
auth_store["credential_pool"] = 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)
|
return _save_auth_store(auth_store)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,9 @@ def get_secret_source(env_var: str) -> str | None:
|
|||||||
Returns ``"bitwarden"`` for keys pulled from Bitwarden Secrets Manager
|
Returns ``"bitwarden"`` for keys pulled from Bitwarden Secrets Manager
|
||||||
during the current process's ``load_hermes_dotenv()`` call. Returns
|
during the current process's ``load_hermes_dotenv()`` call. Returns
|
||||||
``None`` for keys that came from ``.env``, the shell environment, or
|
``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)
|
return _SECRET_SOURCES.get(env_var)
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
def test_load_pool_prefers_dotenv_over_stale_os_environ(tmp_path, monkeypatch):
|
||||||
"""Regression for #18254: stale OPENROUTER_API_KEY in os.environ (inherited
|
"""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
|
from a parent shell) must NOT shadow the fresh key in ~/.hermes/.env when
|
||||||
|
|||||||
@ -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.
|
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
|
## Delegation & Subagent Sharing
|
||||||
|
|
||||||
When the agent spawns subagents via `delegate_task`, the parent's credential pool is automatically shared with children:
|
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",
|
"auth_type": "api_key",
|
||||||
"priority": 0,
|
"priority": 0,
|
||||||
"source": "env:OPENROUTER_API_KEY",
|
"source": "env:OPENROUTER_API_KEY",
|
||||||
"access_token": "sk-or-v1-...",
|
"secret_source": "bitwarden",
|
||||||
|
"secret_fingerprint": "sha256:12ab34cd56ef7890",
|
||||||
"last_status": "ok",
|
"last_status": "ok",
|
||||||
"request_count": 142
|
"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`):
|
Strategies are stored in `config.yaml` (not `auth.json`):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
Reference in New Issue
Block a user