| """Per-server credentials, pins, operator grants, and the admission gate. |
| |
| This module is the agent's half of the handshake and it is the security |
| boundary. The server filters what a user may select; that is a convenience and |
| it fails the moment the server is compromised or hostile. What actually holds |
| is :class:`AllowanceGate`, which refuses any job naming a model or tool outside |
| the set the operator approved for the server that sent it, whatever the server |
| says. ``tests/test_handshake_security.py`` exercises that path with a server |
| that has been told nothing and offers work anyway. |
| |
| Three sets, and the distinction between them is the multi-server story: |
| |
| ``declared`` |
| The machine-wide ceiling, stated once at boot. Never leaves the host. |
| ``offered`` |
| Per server. What that server is even told exists, chosen by the operator |
| when the server is added. This is what goes in ``AgentCapabilities`` at |
| pairing, so a second server cannot learn that the first was offered a larger |
| model. |
| ``granted`` |
| Per server. ``offered`` intersected with the server's catalogue, then |
| narrowed by an explicit operator approval. Empty until that approval |
| happens. This is what is advertised, and what the gate enforces. |
| |
| The invariant ``granted <= offered <= declared`` holds at every point and is |
| asserted rather than assumed. |
| |
| Key material |
| ------------ |
| |
| Credentials live in memory by default, because a restart is then a blunt but |
| complete revocation and the README already promises it. Persistence is opt-in |
| via ``path=``. On POSIX the directory is created ``0o700`` and the file |
| ``0o600``, both at creation time rather than afterwards, so there is no window |
| where the secret is world-readable. **On Windows the file inherits the user |
| profile ACL**: that keeps it from other user accounts on the machine and does |
| nothing at all against another process running as the same user. Say so rather |
| than implying a protection that is not there. |
| |
| Rotation replaces the secret atomically (write a sibling temporary file, then |
| ``os.replace``) and resets the snapshot counter, because a counter is only |
| meaningful relative to the key that signs it. Rotation does **not** reopen the |
| approval question: the operator approved a set of models and tools, not a key. |
| A changed catalogue reopens it; a changed key does not. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import os |
| import secrets |
| import stat |
| import tempfile |
| import threading |
| import time |
| from collections.abc import Callable, Iterable, Mapping, Sequence |
| from dataclasses import dataclass, field |
| from pathlib import Path |
|
|
| from distinct_protocol import JobSpec |
| from distinct_protocol.handshake import ( |
| NOTHING, |
| SNAPSHOT_ALGORITHM, |
| Allowance, |
| HandshakeError, |
| OperatorApproval, |
| ServerCatalogue, |
| SignedSnapshot, |
| ed25519_public_key, |
| generate_ed25519_key, |
| ) |
|
|
| |
| |
| |
| |
| |
| REASON_NOT_APPROVED = "not_approved" |
| REASON_MODEL_NOT_ALLOWED = "model_not_allowlisted" |
| REASON_TOOL_NOT_ALLOWED = "tool_not_allowlisted" |
| REASON_REVOKED = "revoked_by_operator" |
| REASON_UNKNOWN_SERVER = "unknown_server" |
|
|
| |
| |
| |
| REASON_DENIED_BY_AGENT = "denied_by_agent" |
| REASON_UNAUTHENTICATED = "agent_requires_sign_in" |
|
|
| WIRE_REASONS = frozenset( |
| { |
| REASON_NOT_APPROVED, |
| REASON_MODEL_NOT_ALLOWED, |
| REASON_TOOL_NOT_ALLOWED, |
| REASON_REVOKED, |
| REASON_UNKNOWN_SERVER, |
| REASON_DENIED_BY_AGENT, |
| REASON_UNAUTHENTICATED, |
| } |
| ) |
|
|
|
|
| class AllowanceError(RuntimeError): |
| """A handshake step was attempted out of order or with bad input.""" |
|
|
|
|
| class NotApproved(AllowanceError): |
| """No operator approval exists for this server yet.""" |
|
|
|
|
| class PinMismatch(AllowanceError): |
| """The server presented a different identity from the one pinned.""" |
|
|
|
|
| class UnknownServer(AllowanceError): |
| """No record exists for this server id.""" |
|
|
|
|
| @dataclass(frozen=True) |
| class Refusal: |
| """Why a job was refused. ``reason`` is safe to send; ``detail`` is not.""" |
|
|
| reason: str |
| detail: str |
|
|
| def __post_init__(self) -> None: |
| if self.reason not in WIRE_REASONS: |
| raise AllowanceError(f"refusal reason {self.reason!r} is not in the closed set") |
|
|
|
|
| @dataclass(frozen=True) |
| class RevocationOutcome: |
| """What a revocation removed and which local jobs it invalidates.""" |
|
|
| server_id: str |
| removed: Allowance |
| remaining: Allowance |
| |
| |
| |
| |
| invalidated_job_ids: tuple[str, ...] = () |
|
|
|
|
| @dataclass |
| class ServerRecord: |
| """Everything the agent knows about one coordinator. |
| |
| ``secret`` is a ``bytearray`` so it can be overwritten on rotation. Python |
| gives no guarantee the bytes are gone from the heap after that, and this |
| docstring is the honest version of that claim: overwriting removes the value |
| from this object, not necessarily from the process. |
| """ |
|
|
| server_id: str |
| url: str |
| agent_id: str |
| pin: str |
| offered: Allowance |
| secret: bytearray = field(default_factory=bytearray, repr=False) |
| |
| |
| |
| |
| |
| identity_key: bytearray = field(default_factory=bytearray, repr=False) |
| catalogue: ServerCatalogue | None = None |
| granted: Allowance = NOTHING |
| approval: OperatorApproval | None = None |
| counter: int = 0 |
| revoked: bool = False |
|
|
| def __repr__(self) -> str: |
| return ( |
| f"ServerRecord(server_id={self.server_id!r}, agent_id={self.agent_id!r}, " |
| f"granted={self.granted!r}, revoked={self.revoked!r})" |
| ) |
|
|
|
|
| class ServerRegistry: |
| """Thread-safe store of per-server records, grants and counters.""" |
|
|
| def __init__( |
| self, |
| declared: Allowance, |
| *, |
| path: str | os.PathLike[str] | None = None, |
| clock: Callable[[], float] = time.time, |
| ) -> None: |
| if not isinstance(declared, Allowance): |
| raise AllowanceError("declared allowance must be an Allowance") |
| self.declared = declared |
| self._clock = clock |
| self._path = Path(path) if path is not None else None |
| self._lock = threading.RLock() |
| self._records: dict[str, ServerRecord] = {} |
| if self._path is not None and self._path.exists(): |
| self._load() |
|
|
| |
|
|
| def register( |
| self, |
| server_id: str, |
| *, |
| url: str, |
| agent_id: str, |
| pin: str, |
| secret: bytes | bytearray, |
| offer: Allowance, |
| identity_key: bytes | bytearray | None = None, |
| replace_existing: bool = False, |
| ) -> ServerRecord: |
| """Record a freshly paired server and the subset it was offered. |
| |
| ``offer`` is required rather than defaulted to the declared allowance. |
| Defaulting it would mean that adding a second server silently disclosed |
| everything the first one was offered, which is the leak this separation |
| exists to prevent. |
| |
| ``replace_existing`` is for re-pairing with a server already on file. |
| That is an ordinary thing to do: pairing codes are one-use and |
| short-lived, so rejoining a server means a new code and new |
| credentials, and refusing it would make the registry's memory a reason |
| the worker could not start. It replaces the record outright, including |
| dropping any approval attached to the old one, because an approval is |
| bound to a catalogue digest and the new pairing has to re-take it. It |
| is off by default so that the first registration of a duplicate id is |
| still an error rather than a silent overwrite. |
| """ |
|
|
| if not isinstance(offer, Allowance): |
| raise AllowanceError("offer must be an Allowance") |
| if not offer.issubset(self.declared): |
| outside = offer.difference(self.declared) |
| raise AllowanceError( |
| "offer exceeds the declared allowance: " |
| f"models {list(outside.models)}, tools {list(outside.tools)}" |
| ) |
| if not isinstance(secret, (bytes, bytearray)) or len(secret) < 32: |
| raise AllowanceError("server secret must contain at least 32 bytes") |
| with self._lock: |
| if server_id in self._records and not replace_existing: |
| raise AllowanceError(f"server {server_id!r} is already registered") |
| if identity_key is not None and len(identity_key) != 32: |
| raise AllowanceError("an Ed25519 private key is exactly 32 bytes") |
| record = ServerRecord( |
| server_id=server_id, |
| url=url, |
| agent_id=agent_id, |
| pin=pin, |
| offered=offer, |
| secret=bytearray(secret), |
| |
| |
| |
| |
| identity_key=bytearray(identity_key or generate_ed25519_key()), |
| ) |
| self._records[server_id] = record |
| self._persist_locked() |
| return record |
|
|
| def public_key(self, server_id: str) -> bytes: |
| """The 32 raw bytes a server pins to check this agent's advertisements. |
| |
| Sent once, at pairing. Everything after that is verifiable by anyone |
| holding it, which is the property a shared secret cannot provide. |
| """ |
|
|
| with self._lock: |
| return ed25519_public_key(self._require_locked(server_id).identity_key) |
|
|
| def verify_pin(self, server_id: str, presented: str) -> None: |
| """Halt on a changed server identity rather than carrying on. |
| |
| Trust on first use: the first pin is taken on faith, which is weak |
| against somebody already in position at that moment and strong against |
| everything afterwards. A mismatch is not a warning and not a retry; the |
| caller must stop polling that server until an operator re-approves. |
| """ |
|
|
| record = self.require(server_id) |
| if not secrets.compare_digest(record.pin, str(presented)): |
| raise PinMismatch( |
| f"server {server_id!r} presented identity {str(presented)[:16]!r}..., " |
| f"pinned {record.pin[:16]!r}.... Polling stops until an operator " |
| "re-approves this server." |
| ) |
|
|
| |
|
|
| def record_catalogue(self, server_id: str, catalogue: ServerCatalogue) -> Allowance: |
| """Store a pulled catalogue and return what the operator may approve. |
| |
| The choice set is ``catalogue.allowance & offered``. A catalogue |
| entry the operator never offered this server is not merely refused |
| later, it is never shown, so it cannot be approved by accident. |
| Recording a catalogue whose digest differs from the approved one |
| **withdraws the existing grant**: consent was given for a specific set |
| of permissions and a different set has not been consented to. |
| """ |
|
|
| if not isinstance(catalogue, ServerCatalogue): |
| raise AllowanceError("catalogue must be a ServerCatalogue") |
| with self._lock: |
| record = self._require_locked(server_id) |
| if catalogue.server_id != server_id: |
| raise AllowanceError("catalogue was issued by a different server") |
| record.catalogue = catalogue |
| if ( |
| record.approval is not None |
| and record.approval.catalogue_digest != catalogue.digest() |
| ): |
| record.granted = NOTHING |
| record.approval = None |
| self._persist_locked() |
| return catalogue.allowance.intersect(record.offered) |
|
|
| def pending_choice(self, server_id: str) -> Allowance: |
| """What is awaiting an operator decision for this server.""" |
|
|
| with self._lock: |
| record = self._require_locked(server_id) |
| if record.catalogue is None: |
| return NOTHING |
| return record.catalogue.allowance.intersect(record.offered) |
|
|
| def approve(self, server_id: str, approval: OperatorApproval) -> Allowance: |
| """Apply an operator's decision. There is no auto-accept path. |
| |
| Every one of these checks has to pass, and each exists because skipping |
| it would let something other than a human widen the grant: |
| |
| * a catalogue must have been pulled, so there is something to approve; |
| * the approval's digest must match that catalogue, so consent given for |
| one set of permissions cannot be carried over to another; |
| * the approval must be for this server, so an approval for a different |
| coordinator cannot be replayed here; |
| * the approved set must sit inside the choice set, so approval can only |
| narrow. |
| """ |
|
|
| if not isinstance(approval, OperatorApproval): |
| raise AllowanceError("approval must be an OperatorApproval") |
| with self._lock: |
| record = self._require_locked(server_id) |
| if record.catalogue is None: |
| raise AllowanceError( |
| f"no catalogue has been pulled from {server_id!r}; there is " |
| "nothing for an operator to approve" |
| ) |
| if approval.server_id != server_id: |
| raise AllowanceError( |
| "approval names a different server; approvals are per server " |
| "and are never shared between them" |
| ) |
| expected = record.catalogue.digest() |
| if not secrets.compare_digest(approval.catalogue_digest, expected): |
| raise AllowanceError( |
| "approval is bound to a different catalogue. The server has " |
| "changed what it asks for since the operator was shown it, " |
| "so the earlier consent does not carry over." |
| ) |
| choice = record.catalogue.allowance.intersect(record.offered) |
| if not approval.allowance.issubset(choice): |
| outside = approval.allowance.difference(choice) |
| raise AllowanceError( |
| "approval exceeds what was offered to this server: " |
| f"models {list(outside.models)}, tools {list(outside.tools)}" |
| ) |
| record.granted = approval.allowance |
| record.approval = approval |
| record.revoked = False |
| self._persist_locked() |
| return record.granted |
|
|
| def granted(self, server_id: str) -> Allowance: |
| """The approved set for one server, and only that server.""" |
|
|
| with self._lock: |
| record = self._records.get(server_id) |
| if record is None or record.revoked: |
| return NOTHING |
| return record.granted |
|
|
| |
|
|
| def revoke( |
| self, |
| server_id: str, |
| *, |
| models: Sequence[str] = (), |
| tools: Sequence[str] = (), |
| jobs: Iterable[JobSpec] = (), |
| ) -> RevocationOutcome: |
| """Narrow a grant and name the local work that must now stop. |
| |
| Withdrawing permission is present tense. A job that is merely queued is |
| cancelled outright: it has not started, and letting it through would run |
| exactly the thing the operator just refused. A job already running is |
| also stopped, through the same cooperative cancellation the user-facing |
| cancel uses, so the runner unwinds cleanly instead of being killed part |
| way through a write. Finishing "just this one" would be the wrong |
| default: the operator revoked to stop it, not to stop the next one. |
| |
| The narrowing and the sweep happen under one lock, so no job can be |
| admitted against the old grant after the new one is in force. |
| """ |
|
|
| removed = Allowance(models=tuple(models), tools=tuple(tools)) |
| with self._lock: |
| record = self._require_locked(server_id) |
| before = record.granted |
| record.granted = before.difference(removed) |
| if not record.granted: |
| record.revoked = True |
| invalidated = tuple( |
| job.id |
| for job in jobs |
| if isinstance(job, JobSpec) and _job_refusal(job, record.granted) is not None |
| ) |
| self._persist_locked() |
| return RevocationOutcome( |
| server_id=server_id, |
| removed=before.intersect(removed), |
| remaining=record.granted, |
| invalidated_job_ids=invalidated, |
| ) |
|
|
| def forget(self, server_id: str) -> None: |
| """Drop a server entirely and overwrite its secret.""" |
|
|
| with self._lock: |
| record = self._records.pop(server_id, None) |
| if record is not None: |
| _zeroise(record.secret) |
| _zeroise(record.identity_key) |
| self._persist_locked() |
|
|
| def rotate_secret(self, server_id: str, secret: bytes | bytearray) -> None: |
| """Replace the transport secret; the operator's approval is untouched. |
| |
| This is the symmetric channel key, rotated by the server. The agent's |
| Ed25519 identity is a different thing and is deliberately not touched |
| here: see :meth:`rotate_identity`. |
| """ |
|
|
| if not isinstance(secret, (bytes, bytearray)) or len(secret) < 32: |
| raise AllowanceError("server secret must contain at least 32 bytes") |
| with self._lock: |
| record = self._require_locked(server_id) |
| _zeroise(record.secret) |
| record.secret = bytearray(secret) |
| self._persist_locked() |
|
|
| def rotate_identity(self, server_id: str) -> bytes: |
| """Replace the signing identity, and invalidate the server's pin. |
| |
| Rotating the transport secret is routine and changes nothing an operator |
| agreed to. **Rotating the identity is not routine.** The server pinned |
| the old public key, so every advertisement signed with the new one will |
| fail verification until the operator re-pairs and the server pins the |
| new key. That is correct rather than inconvenient: the pin *is* the |
| identity, and an identity that could be swapped silently would prove |
| nothing. |
| |
| The counter resets, because a counter only orders assertions made under |
| one key; carrying it over would let a snapshot signed with the old key |
| and one signed with the new key claim the same position. |
| """ |
|
|
| with self._lock: |
| record = self._require_locked(server_id) |
| _zeroise(record.identity_key) |
| record.identity_key = bytearray(generate_ed25519_key()) |
| record.counter = 0 |
| self._persist_locked() |
| return ed25519_public_key(record.identity_key) |
|
|
| |
|
|
| def sign_snapshot(self, server_id: str, snapshot) -> SignedSnapshot: |
| """Advertise to one server exactly what it was granted, and sign it. |
| |
| The counter increments once per snapshot per server. Two servers never |
| see the same counter value for the same state, and neither can infer the |
| other's poll rate from it. |
| """ |
|
|
| with self._lock: |
| record = self._require_locked(server_id) |
| record.counter += 1 |
| envelope = SignedSnapshot( |
| snapshot=snapshot, |
| advertised=NOTHING if record.revoked else record.granted, |
| counter=record.counter, |
| algorithm=SNAPSHOT_ALGORITHM, |
| |
| |
| |
| ).signed(bytes(record.identity_key)) |
| self._persist_locked() |
| return envelope |
|
|
| def secret_for(self, server_id: str) -> bytes: |
| with self._lock: |
| return bytes(self._require_locked(server_id).secret) |
|
|
| def require(self, server_id: str) -> ServerRecord: |
| with self._lock: |
| return self._require_locked(server_id) |
|
|
| def server_ids(self) -> tuple[str, ...]: |
| with self._lock: |
| return tuple(sorted(self._records)) |
|
|
| def _require_locked(self, server_id: str) -> ServerRecord: |
| record = self._records.get(server_id) |
| if record is None: |
| raise UnknownServer(f"no record for server {server_id!r}") |
| return record |
|
|
| |
|
|
| def _persist_locked(self) -> None: |
| if self._path is None: |
| return |
| payload = { |
| "declared": self.declared.to_dict(), |
| "servers": [ |
| { |
| "server_id": record.server_id, |
| "url": record.url, |
| "agent_id": record.agent_id, |
| "pin": record.pin, |
| "offered": record.offered.to_dict(), |
| "granted": record.granted.to_dict(), |
| "approval": None if record.approval is None else record.approval.to_dict(), |
| "catalogue": None if record.catalogue is None else record.catalogue.to_dict(), |
| "secret": bytes(record.secret).hex(), |
| "identity_key": bytes(record.identity_key).hex(), |
| "counter": record.counter, |
| "revoked": record.revoked, |
| } |
| for record in (self._records[key] for key in sorted(self._records)) |
| ], |
| } |
| _write_private(self._path, json.dumps(payload, sort_keys=True, indent=2)) |
|
|
| def _load(self) -> None: |
| assert self._path is not None |
| raw = json.loads(self._path.read_text(encoding="utf-8")) |
| if not isinstance(raw, Mapping): |
| raise AllowanceError("server store is not a JSON object") |
| for item in raw.get("servers") or (): |
| if not isinstance(item, Mapping): |
| raise AllowanceError("server store contains a malformed record") |
| approval = item.get("approval") |
| catalogue = item.get("catalogue") |
| record = ServerRecord( |
| server_id=str(item.get("server_id", "")), |
| url=str(item.get("url", "")), |
| agent_id=str(item.get("agent_id", "")), |
| pin=str(item.get("pin", "")), |
| offered=Allowance.from_dict(item.get("offered") or {}), |
| secret=bytearray(bytes.fromhex(str(item.get("secret", "")))), |
| identity_key=bytearray(bytes.fromhex(str(item.get("identity_key", "")))), |
| catalogue=None if catalogue is None else ServerCatalogue.from_dict(catalogue), |
| granted=Allowance.from_dict(item.get("granted") or {}), |
| approval=None if approval is None else OperatorApproval.from_dict(approval), |
| counter=int(item.get("counter", 0)), |
| revoked=bool(item.get("revoked", False)), |
| ) |
| |
| |
| |
| record.offered = record.offered.intersect(self.declared) |
| record.granted = record.granted.intersect(record.offered) |
| if len(record.identity_key) != 32: |
| |
| |
| |
| |
| record.identity_key = bytearray(generate_ed25519_key()) |
| record.counter = 0 |
| self._records[record.server_id] = record |
|
|
|
|
| class AllowanceGate: |
| """The agent-side check. One server, one grant, no server input. |
| |
| Held deliberately small and free of any dependency on the transport, so the |
| thing that decides whether a job may run can be read in one sitting and |
| tested without a network, a queue or a server. |
| """ |
|
|
| def __init__(self, registry: ServerRegistry, server_id: str) -> None: |
| self.registry = registry |
| self.server_id = server_id |
|
|
| def check(self, job: JobSpec) -> Refusal | None: |
| """Return ``None`` to admit, or a :class:`Refusal` to refuse. |
| |
| Refusing rather than raising is deliberate: a refusal is a normal, |
| expected outcome that has to be reported back to the server and shown to |
| the user, not an error condition on the worker. |
| """ |
|
|
| if not isinstance(job, JobSpec): |
| raise TypeError("job must be a distinct_protocol.JobSpec") |
| try: |
| record = self.registry.require(self.server_id) |
| except UnknownServer: |
| return Refusal( |
| REASON_UNKNOWN_SERVER, |
| f"job {job.id} arrived for unregistered server {self.server_id!r}", |
| ) |
| if record.revoked: |
| return Refusal( |
| REASON_REVOKED, |
| f"server {self.server_id!r} was revoked by the operator", |
| ) |
| granted = self.registry.granted(self.server_id) |
| if not granted: |
| return Refusal( |
| REASON_NOT_APPROVED, |
| f"no operator approval exists for server {self.server_id!r}; " |
| "the agent advertises nothing and accepts nothing", |
| ) |
| return _job_refusal(job, granted) |
|
|
| def advertised(self) -> Allowance: |
| return self.registry.granted(self.server_id) |
|
|
|
|
| def _job_refusal(job: JobSpec, granted: Allowance) -> Refusal | None: |
| """The one place a job is measured against a grant. |
| |
| Shared by the gate and by revocation so that "would this job be admitted?" |
| and "does this running job still qualify?" can never answer differently. |
| """ |
|
|
| if not granted.allows_model(job.model_id): |
| return Refusal( |
| REASON_MODEL_NOT_ALLOWED, |
| f"model {job.model_id!r} is not in the approved set for this server", |
| ) |
| for selection in job.allowed_tools: |
| ref = f"{selection.id}@{selection.version}" |
| if not granted.allows_tool(ref): |
| return Refusal( |
| REASON_TOOL_NOT_ALLOWED, |
| f"tool {ref!r} is not in the approved set for this server", |
| ) |
| return None |
|
|
|
|
| def _zeroise(buffer: bytearray) -> None: |
| """Overwrite a secret in place. |
| |
| This clears the value from this object. It does not promise the bytes are |
| gone from the process: Python may have copied them, and there is no portable |
| way to find or clear those copies. |
| """ |
|
|
| for index in range(len(buffer)): |
| buffer[index] = 0 |
| del buffer[:] |
|
|
|
|
| def _write_private(path: Path, text: str) -> None: |
| """Write a secret-bearing file, never leaving it readable in between. |
| |
| The temporary file is created in the destination directory with the final |
| permissions already set, then renamed over the target. A reader either sees |
| the old complete file or the new complete file, and never a partial one or a |
| world-readable one. |
| """ |
|
|
| directory = path.parent |
| directory.mkdir(parents=True, exist_ok=True) |
| if os.name != "nt": |
| os.chmod(directory, stat.S_IRWXU) |
| handle, temporary = tempfile.mkstemp(dir=str(directory), prefix=".servers-", suffix=".tmp") |
| try: |
| if os.name != "nt": |
| os.fchmod(handle, stat.S_IRUSR | stat.S_IWUSR) |
| with os.fdopen(handle, "w", encoding="utf-8") as stream: |
| stream.write(text) |
| stream.flush() |
| os.fsync(stream.fileno()) |
| os.replace(temporary, path) |
| except BaseException: |
| os.unlink(temporary) |
| raise |
|
|
|
|
| def storage_warning() -> str: |
| """The line an operator must see before credentials touch a disk.""" |
|
|
| if os.name == "nt": |
| return ( |
| "Stored agent credentials inherit this user profile's file " |
| "permissions. That keeps them from other user accounts on this " |
| "machine and does nothing against another process running as you. " |
| "Without --remember-servers the credentials stay in memory and a " |
| "restart revokes every pairing." |
| ) |
| return ( |
| "Stored agent credentials are written 0600 in a 0700 directory, " |
| "readable only by this user account. Without --remember-servers the " |
| "credentials stay in memory and a restart revokes every pairing." |
| ) |
|
|
|
|
| __all__ = [ |
| "REASON_DENIED_BY_AGENT", |
| "REASON_MODEL_NOT_ALLOWED", |
| "REASON_NOT_APPROVED", |
| "REASON_REVOKED", |
| "REASON_TOOL_NOT_ALLOWED", |
| "REASON_UNAUTHENTICATED", |
| "REASON_UNKNOWN_SERVER", |
| "WIRE_REASONS", |
| "AllowanceError", |
| "AllowanceGate", |
| "HandshakeError", |
| "NotApproved", |
| "PinMismatch", |
| "Refusal", |
| "RevocationOutcome", |
| "ServerRecord", |
| "ServerRegistry", |
| "UnknownServer", |
| "storage_warning", |
| ] |
|
|