mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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
84 lines
4.0 KiB
Python
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()
|