Files
hermes-agent/gateway/session_policy_credentials.py
Teknium 3e920deab6 fix(gateway): a legacy custom provider renamed in place still runs on cold start
Review on #141484: when no current custom_providers entry carries the
frozen entry's identity, but the entry at the frozen index still points
at the same endpoint URL, recovery reads that index again, as main did,
and the key fingerprint decides. A rename in place (same index, URL and
key) runs again; deletion, a renamed entry moved to another index, and a
duplicate-name swap behave as before.

Refs #134889 (MP-17)
2026-10-19 11:41:34 -07:00

390 lines
21 KiB
Python

"""Borrow frozen config credentials from the existing profile source, not a vault."""
import hashlib
import hmac
import json
import logging
from dataclasses import asdict
from pathlib import Path
from agent.credential_persistence import fingerprint_secret_value
from hermes_state_runtime import RuntimeStoreError
logger = logging.getLogger(__name__)
PREFIX = 'profile-config-v1:'
_ABSENT = object()
def _defined(root, path):
"""The value *root* defines at *path*, else ``_ABSENT`` when the key is simply not there."""
value = root
for key in path:
if isinstance(value, list) and type(key) is int:
if not 0 <= key < len(value):
return _ABSENT
elif not isinstance(value, dict) or key not in value:
return _ABSENT
value = value[key]
return value
def _route_paths(policy):
"""Secret paths the session's own route authenticates with: its ``model:`` block, a direct alias's
key (``api_key_from``) and the one ``providers:`` / ``custom_providers:`` entry the runtime's own
named-provider selection routes its provider through."""
from hermes_cli.runtime_provider_custom import (custom_provider_config_path_for_url,
named_custom_provider_config_path)
config = json.loads(policy.config_json)
model = config.get('model') if isinstance(config.get('model'), dict) else {}
paths = {('model', key) for key in model}
if isinstance(model.get('api_key_from'), list):
paths.add(tuple(model['api_key_from']))
provider = str(model.get('provider') or '')
if provider.strip().lower() == 'custom':
# A bare custom route authenticates with the entry owning its base_url (the pool's owner).
selected = custom_provider_config_path_for_url(model.get('base_url'), config)
else:
selected = named_custom_provider_config_path(provider, config)
entry = _defined(config, selected) if selected else _ABSENT
if isinstance(entry, dict):
paths |= {selected + (field,) for field in entry}
return paths
class RecoveredSecrets(dict):
"""The frozen secrets still valid, plus ``dropped``: the paths whose value changed or was removed
in config.yaml since the session started (never supplied; the policy disables what used them)."""
dropped = ()
session = None
def policy_identity(policy, unhashed=()):
data = asdict(policy)
data.pop('credential_ref')
data.pop('config_secret_ref')
# Fields added after release are hashed only when set, so an older session's reference still matches.
if data.get('editor_mcp_json') is None:
data.pop('editor_mcp_json', None)
if not data.get('coding_posture'):
data.pop('coding_posture', None)
for name in ('client_env_json', 'client_env_rules_json'):
if data.get(name) is None:
data.pop(name, None)
if not data.get('client_env_remote'):
data.pop('client_env_remote', None)
if data.get('edit_approval_policy', 'ask') == 'ask':
data.pop('edit_approval_policy', None)
for name in unhashed: # _legacy_identities: a field the stamping gateway did not have yet
data.pop(name, None)
return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()
# Policy fields a release added after frozen config secrets shipped, hashed unconditionally by the
# gateway that added them. A reference an older gateway stamped never hashed them; at their defaults
# they carry nothing that gateway could have seen, so the same policy without them is its policy.
# The commit that added each, to audit this list against the stamping releases:
_LATER_POLICY_FIELDS = (('skills_prompt', None), # 2ee1cd1ebb02
('kanban_json', None), # c8f8d9ae6384
('safe_mode', False), # 53d362e18741
('ignore_user_config', False)) # 53d362e18741
def _legacy_identities(policy):
"""Every whole-policy hash a gateway older than ``bound`` could have stamped for *policy*: the
current hash, plus each subset of the later fields dropped while they are at their defaults."""
from itertools import combinations
unset = [name for name, default in _LATER_POLICY_FIELDS if getattr(policy, name) == default]
return {policy_identity(policy, dropped)
for size in range(len(unset) + 1) for dropped in combinations(unset, size)}
def _fingerprint(value):
return fingerprint_secret_value(value if isinstance(value, str) else json.dumps(value, sort_keys=True))
# What a frozen secret authenticates: the endpoint fields of the block that holds it (``model:``, a
# ``providers:`` entry, an MCP server), plus the endpoint its route RESOLVES to when that lives in another
# block. A secret is bound to these, never to the rest of the policy. ``api`` is an entry's URL field
# (runtime_provider_custom._entry_url reads it before ``url``/``base_url``).
_ENDPOINT_KEYS = ('provider', 'base_url', 'api', 'url', 'command', 'args', 'api_mode')
_ENTRY_SECTIONS = ('providers', 'custom_providers')
# An auxiliary route naming no provider of its own (or ``main``/``auto``) goes where the session's
# ``model:`` route goes, so its key authenticates that endpoint too.
_AUX_FOLLOWS_MAIN = ('', 'auto', 'main')
def _route_owner(config, path):
"""The config path of the ROUTE block a secret at *path* rides with, else None. Every route shape a
consumer reads: ``model:`` and its aliases (``model.aliases.<n>``, ``model_aliases.<n>``), both
fallback sections in dictionary (one route) and list form, and each ``auxiliary.<task>`` route with
its ``fallback_chain`` entries. A secret nested deeper in a route (headers, extra_body) rides with it."""
head, rest = path[0], tuple(path[1:])
model = config.get('model') if isinstance(config.get('model'), dict) else {}
if isinstance(model.get('api_key_from'), list) and tuple(model['api_key_from']) == tuple(path):
# ``/model <alias>`` with a literal key: config() projects the alias entry's key into ``model.api_key``,
# so it authenticates the session's ``model:`` route, not the alias entry it is stored under.
return ('model',)
if head == 'model':
if rest[:1] == ('aliases',) and len(rest) > 2:
return ('model', 'aliases', rest[1])
return ('model',)
if head == 'model_aliases' and len(rest) > 1:
return ('model_aliases', rest[0])
if head in ('fallback_model', 'fallback_providers'):
if isinstance(config.get(head), list) and rest and type(rest[0]) is int:
return (head, rest[0])
return (head,)
if head == 'auxiliary' and len(rest) > 1:
task = rest[0]
if rest[1] == 'fallback_chain' and len(rest) > 3 and type(rest[2]) is int:
return ('auxiliary', task, 'fallback_chain', rest[2])
return ('auxiliary', task)
return None
def _owner_block(config, path):
"""The config path of the block whose endpoint the secret at *path* authenticates: a route block
(_route_owner) or a provider entry as a whole, else the secret's own parent block."""
route = _route_owner(config, path)
if route is not None:
return route
if path[0] in _ENTRY_SECTIONS:
return tuple(path[:2])
return tuple(path[:-1])
def _resolved(owner, block, config):
"""``(provider_identity, url)`` the route *block* at *owner* is actually sent to."""
from gateway.session_route_identity import resolved_route
if owner[0] == 'auxiliary':
provider = str(block.get('provider') or '').strip().lower()
if provider in _AUX_FOLLOWS_MAIN and not str(block.get('base_url') or '').strip():
main = config.get('model') if isinstance(config.get('model'), dict) else {}
return ['main', *resolved_route(main, config)]
return list(resolved_route(block, config))
# Endpoint fields holding a URL: digested in normalize_route_base_url's form, so a re-spelling of the same
# endpoint (host case, a trailing slash, the scheme's default port) is not a moved endpoint.
_URL_KEYS = ('base_url', 'api', 'url')
def _url(value):
from hermes_cli.route_identity import normalize_route_base_url
return normalize_route_base_url(str(value or '').strip())
def _binding(config, path):
"""Digest of the endpoint the secret at *path* authenticates in the frozen *config* (a terminal-scope
secret, ``path[0] is None``, authenticates no endpoint)."""
owner = _owner_block(config, path) if path and path[0] is not None else None
block = _defined(config, owner) if owner else _ABSENT
endpoint = {k: block[k] for k in _ENDPOINT_KEYS if k in block} if isinstance(block, dict) else {}
if isinstance(block, dict) and _route_owner(config, path) is not None:
# A route's key authenticates what it resolves to: a named ``providers:`` entry's URL included.
endpoint['resolved'] = _resolved(owner, block, config)
endpoint['resolved'][-1] = _url(endpoint['resolved'][-1])
elif isinstance(block, dict) and path[0] in _ENTRY_SECTIONS:
from hermes_cli.runtime_provider_custom import _entry_url
endpoint['resolved'] = _url(_entry_url(block))
for key in _URL_KEYS:
if isinstance(endpoint.get(key), str):
endpoint[key] = _url(endpoint[key])
return hashlib.sha256(json.dumps([list(path), endpoint], sort_keys=True).encode()).hexdigest()
def config_reference(authority, session_id, policy, secrets):
home = Path(authority.db.db_path).resolve().parent
config = json.loads(policy.config_json)
return PREFIX + json.dumps({'home': str(home), 'profile': authority.profile_id,
# ``policy`` (the whole-policy hash) is still written for an older gateway reading this reference;
# this gateway checks ``bound``: each secret against the endpoint it authenticates (CFG-10).
'session': session_id, 'policy': policy_identity(policy),
'bound': [[list(path), _binding(config, path)] for path in secrets],
'entries': [[list(path), _fingerprint(value)] for path, value in secrets.items()]},
sort_keys=True)
def _field(path):
return '.'.join(str(part) for part in path if part is not None)
def _check_scope(authority, source, policy, home, skip):
"""Refuse, by name, a reference from another home or profile, or a secret whose endpoint the policy
moved without re-binding it. A change to anything else in the policy (model name, reasoning, toolsets,
an unrelated provider or MCP server, a policy field a later release added) is not a credential change."""
for name, mine in (('home', str(home)), ('profile', authority.profile_id)):
if source[name] != mine:
raise RuntimeStoreError('launch_credentials_unavailable', field=name,
detail=f'the frozen config credentials belong to another {name}')
if 'bound' not in source:
# Stamped before ``bound``: its whole-policy hash is its only endpoint guard, so it must still
# match -- allowing for the policy fields added since, at their defaults (_legacy_identities).
# The next re-bind writes ``bound``.
if source['policy'] not in _legacy_identities(policy):
raise RuntimeStoreError('launch_credentials_unavailable', field='config_secret_ref',
detail='stamped by an older gateway for another session policy')
return
config = json.loads(policy.config_json)
for path, digest in source['bound']:
if path[0] not in skip and not hmac.compare_digest(_binding(config, path), digest):
raise RuntimeStoreError('launch_credentials_unavailable', field=_field(path),
detail='its endpoint moved in the session policy without a re-bind')
def secrets_moved(ref, secrets, sections):
"""Whether *secrets* (the private values of *sections*, as the profile has them now) differ from the ones
the durable reference *ref* froze there, by fingerprint. Unknown for an in-memory reference: False."""
if ref is not None and not ref.startswith(PREFIX):
return False
entries = json.loads(ref[len(PREFIX):])['entries'] if ref else []
frozen = {tuple(path): fingerprint for path, fingerprint in entries if path[0] in sections}
return frozen != {path: _fingerprint(value) for path, value in secrets.items()}
def recover_config_secrets(authority, policy, skip=None):
"""The frozen config secrets; entries under a *skip* section are neither re-read nor checked. Every
caller of a following session skips its followed sections: the live profile supplies them, so a key
rotation there is not a revoked launch credential."""
if skip is None:
from gateway.session_policy import FOLLOW_SECTIONS
skip = FOLLOW_SECTIONS if policy.follows_profile else ()
ref = policy.config_secret_ref
if not ref.startswith(PREFIX):
values = getattr(authority, '_local_config_secrets', {}).get(ref)
if values is None:
raise RuntimeStoreError('launch_credentials_unavailable')
return {path: value for path, value in values.items() if path[0] not in skip}
try:
source = json.loads(ref[len(PREFIX):])
home = Path(authority.db.db_path).resolve().parent
_check_scope(authority, source, policy, home, skip)
cached = getattr(authority, '_local_config_secrets', {}).get(ref)
if cached is not None:
return {path: value for path, value in cached.items() if path[0] not in skip}
from gateway.run import _load_gateway_config
entries = [entry for entry in source['entries'] if entry[0][0] not in skip]
config = _load_gateway_config(home / 'config.yaml') if any(path[0] is not None for path, _ in entries) else {}
route = None # computed on the first mismatch only: an unchanged source never needs it
values, dropped = RecoveredSecrets(), []
frozen = None # parsed on the first legacy entry only
for path, fingerprint in entries:
value, lookup = config, path
if path[0] == 'custom_providers' and len(path) > 2 and type(path[1]) is int:
frozen = json.loads(policy.config_json) if frozen is None else frozen
lookup = _current_custom_path(frozen, config, path)
if path[0] is None:
from tools.terminal_scope import build_profile_terminal_scope
value, lookup = build_profile_terminal_scope(home), path[1:]
value = _defined(value, lookup)
actual = None if value is _ABSENT else _fingerprint(value)
if actual is None or not hmac.compare_digest(actual, fingerprint):
if route is None:
route = _route_paths(policy)
if tuple(path) in route:
raise RuntimeStoreError('launch_credentials_unavailable', field=_field(path),
detail=('removed from' if value is _ABSENT else 'changed in')
+ ' config.yaml since this session started')
# Any other secret (an MCP env token, an unused provider's key) is simply no longer
# supplied: the frozen tree holds None there, never the profile's new value.
logger.warning('Session %s: frozen config secret %s no longer matches the profile '
'config; skipping it', source.get('session'), path)
dropped.append(tuple(path))
continue
values[tuple(path)] = value
values.dropped, values.session = tuple(dropped), source.get('session')
return values
except RuntimeStoreError:
raise
except (KeyError, TypeError, ValueError, AttributeError, OSError) as exc:
logger.warning('Frozen config reference unreadable: %s', type(exc).__name__)
raise RuntimeStoreError('launch_credentials_unavailable', field='config_secret_ref',
detail=f'unreadable ({type(exc).__name__})') from exc
def _current_custom_path(frozen, config, path):
"""*path* (``custom_providers[i]...``, an index into the frozen list) re-pointed at the profile's
entry of the SAME canonical identity (``custom_provider_slug``), wherever a reorder moved it.
An entry the profile no longer declares maps to no index (absent), never to whatever entry now
sits at ``i``, unless that entry is the same endpoint renamed in place (the fingerprint then decides,
as on main). A frozen entry with no name has no identity: its index is all there is."""
from hermes_cli.providers import custom_provider_slug
def identity(entry):
if not isinstance(entry, dict) or not (entry.get('name') or entry.get('provider_key')):
return None
return custom_provider_slug(str(entry.get('name') or ''), str(entry.get('provider_key') or ''))
held = frozen.get('custom_providers')
mine = identity(held[path[1]]) if isinstance(held, list) and 0 <= path[1] < len(held) else None
current = config.get('custom_providers')
if mine is None or not isinstance(current, list):
return path
if 0 <= path[1] < len(current) and identity(current[path[1]]) == mine:
return path # unmoved (also keeps a duplicate-named later entry on its own index)
for index, entry in enumerate(current):
if identity(entry) == mine: # the first match, as the runtime's own name lookup takes
return (path[0], index, *path[2:])
from hermes_cli.runtime_provider_custom import _entry_url, _normalize_base_url_for_match as url
if 0 <= path[1] < len(current) and isinstance(current[path[1]], dict) \
and url(_entry_url(current[path[1]])) == url(_entry_url(held[path[1]])):
return path # renamed in place: same endpoint at the same index, as main read it (the fingerprint decides)
return (path[0], len(current), *path[2:]) # gone: past the end reads as absent
def _entry_owner_path(entry, config):
"""The ``providers:`` / ``custom_providers:`` entry a fallback entry authenticates through."""
from hermes_cli.runtime_provider_custom import (custom_provider_config_path_for_url,
named_custom_provider_config_path)
provider = str(entry.get('provider') or '').strip()
if provider.lower() == 'custom':
return custom_provider_config_path_for_url(entry.get('base_url'), config)
return named_custom_provider_config_path(provider, config) if provider else None
def disable_dropped_secrets(config, dropped, session=None):
"""Disable, in the session's effective *config*, whatever authenticated with a *dropped* frozen
secret, instead of leaving it with a null key (null means 'resolve another credential' and would
send another account's key). A fallback using it is removed from the chain; an ``auxiliary.<task>``
override using it is replaced by the session's primary route (``provider: main``). Never substitutes."""
if not dropped:
return config
from agent.auxiliary_task_config import _AUX_ROUTE_KEYS
auxiliary = config.get('auxiliary') if isinstance(config.get('auxiliary'), dict) else {}
for task in sorted({path[1] for path in dropped if path[0] == 'auxiliary' and len(path) > 2}, key=str):
block = auxiliary.get(task)
if isinstance(block, dict):
# The task's own route is gone with its key: the session's primary route, its own credential.
auxiliary[task] = {**{k: v for k, v in block.items() if k not in _AUX_ROUTE_KEYS
and k != 'fallback_chain'}, 'provider': 'main'}
logger.warning('Session %s: auxiliary task %s returned to the primary route: its credential '
'changed in config.yaml since this session started', session, task)
owners = {path[:2] for path in dropped if path[0] in ('providers', 'custom_providers') and len(path) > 2}
for section in ('fallback_providers', 'fallback_model'):
raw = config.get(section)
if isinstance(raw, dict):
entries, single = [raw], True
elif isinstance(raw, list):
entries, single = list(raw), False
else:
continue
kept = []
for index, entry in enumerate(entries):
prefix = (section,) if single else (section, index)
own = any(path[:len(prefix)] == prefix for path in dropped)
owner = _entry_owner_path(entry, config) if isinstance(entry, dict) and owners else None
if own or (owner is not None and tuple(owner) in owners):
logger.warning('Session %s: fallback %s/%s removed: its credential changed in config.yaml '
'since this session started', session,
(entry or {}).get('provider') if isinstance(entry, dict) else '?',
(entry or {}).get('model') if isinstance(entry, dict) else '?')
continue
kept.append(entry)
if single:
if kept:
config[section] = kept[0]
else:
config.pop(section, None)
else:
config[section] = kept
return config