mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
fix: avoid persisting borrowed credential secrets (#31416)
This commit is contained in:
parent
2b768535c9
commit
d7c5d5dee5
6 changed files with 590 additions and 27 deletions
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_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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue