Files
hermes-agent/gateway/runtime_bootstrap.py
T
Teknium aa00b801b9 fix(profiles): rename and delete a served profile over a dedicated native registry grant
A multiplexing gateway refused PATCH/DELETE /api/profiles/<name> under every
native grant, even one minted for that very profile, so the Desktop Profiles
screen could never rename or delete a profile while a gateway ran (403
profile_scope_mismatch). Separately, the Desktop's registry route appended the
ambient ?profile= (the sidebar's profile, or "__all__") to a path that already
names its profile, so SOUL reads/writes 403'd for every profile but the
active one, and for all of them in All-profiles scope.

One grant design for both:
- gateway/runtime_bootstrap.py: new `native-profile-registry` purpose
  (capability `profiles:registry`), the twin of `native-profile-create`.
- native_http: that grant redeems only for PATCH/DELETE /api/profiles/<name>,
  only for the profile it was minted for, never deletes or moves the
  gateway's launch home (the default root's presentation-only rename stays
  allowed), and is single-use. An ordinary native-http grant keeps refusing
  both writes; a registry grant authorizes nothing else (401).
- Desktop: nativeGrantPurpose() mints the registry grant for those two
  writes; pathWithProfileScope never adds the ambient profile to an
  /api/profiles/<name>/... path (collection routes still get it).

Refs #134889 (DB-5)
Refs #134889 (DB-6)
Refs #140345
Refs #140346
Refs #140791
Refs #140719
2026-10-18 03:27:52 -07:00

84 lines
4.0 KiB
Python

"""Private local bootstrap grants; never put these credentials in discovery."""
from __future__ import annotations
import hashlib
import secrets
import threading
import time
_PURPOSE_CAPABILITIES = {
'interactive': frozenset({'session:create', 'session:read', 'session:submit',
'session:control', 'session:approve', 'session:respond'}),
'native-http': frozenset({'http:owner'}),
# Same-user host operation, not permission to read or mutate another profile.
'native-profile-create': frozenset({'profiles:create'}),
# Rename/delete of the ONE profile it is minted for (#140345): a registry write on that home,
# never a read of it or any other route.
'native-profile-registry': frozenset({'profiles:registry'}),
'exposure': frozenset({'transport:delegate'}),
'worker-adoption': frozenset({'worker:adopt'}),
}
# A grant's profile reach: its own home, or (interactive only) every served sibling.
_SCOPES = frozenset({'profile', 'host'})
class TicketStore:
TTL_SECONDS = 30
MAX_ENTRIES = 1024
def __init__(self, instance_id: str, profile_ids: frozenset[str]):
if not instance_id or not profile_ids:
raise ValueError('bootstrap requires instance and served profiles')
self.instance_id = instance_id
self.profile_ids = frozenset(profile_ids)
self._entries: dict[str, tuple[float, dict]] = {}
self._lock = threading.Lock()
def mint(self, *, profile_id, subject, purpose, scope='profile') -> str:
"""``scope='host'`` lets an interactive connection select every profile this process
serves (the Desktop's one shared socket); the default binds exactly ``profile_id``."""
if (profile_id not in self.profile_ids or purpose not in _PURPOSE_CAPABILITIES or not subject
or scope not in _SCOPES or (scope == 'host' and purpose != 'interactive')):
raise PermissionError('bootstrap binding rejected')
with self._lock:
now = time.monotonic()
self._entries = {k: v for k, v in self._entries.items() if v[0] > now}
if len(self._entries) >= self.MAX_ENTRIES:
raise PermissionError('bootstrap capacity exhausted')
ticket = secrets.token_urlsafe(32)
self._entries[hashlib.sha256(ticket.encode()).hexdigest()] = (
now + self.TTL_SECONDS,
{'instance_id': self.instance_id, 'profile_id': profile_id, 'subject': subject,
'purpose': purpose, 'capabilities': _PURPOSE_CAPABILITIES[purpose], 'scope': scope})
return ticket
def redeem(self, ticket, *, profile_id, purpose) -> dict:
"""``profile_id=None`` accepts a ticket minted for ANY served profile; the returned grant's
``profile_id`` then selects the authority. A named profile must match exactly."""
if not isinstance(ticket, str) or len(ticket) > 256:
raise PermissionError('invalid bootstrap ticket')
key = hashlib.sha256(ticket.encode()).hexdigest()
with self._lock:
entry = self._entries.get(key)
if entry is None:
raise PermissionError('invalid bootstrap ticket')
expires, grant = entry
if expires <= time.monotonic():
del self._entries[key]
raise PermissionError('expired bootstrap ticket')
served = grant['profile_id'] in self.profile_ids if profile_id is None else grant['profile_id'] == profile_id
if not served or grant['purpose'] != purpose:
raise PermissionError('bootstrap binding rejected')
del self._entries[key]
return dict(grant)
def outstanding(self) -> bool:
"""An unexpired ticket was minted and not yet redeemed: a client is attaching right now."""
with self._lock:
now = time.monotonic()
return any(expires > now for expires, _grant in self._entries.values())
def revoke(self) -> None:
with self._lock:
self._entries.clear()