Spaces:
Sleeping
Sleeping
| """Dispatcher: verify the gate's grant, then run the backend. | |
| This is the *execute* half of the "authorize, then execute" split. The gate | |
| (see :mod:`control_plane.governance`) decides and, on a final ``ALLOW``, mints a | |
| signed :class:`~control_plane.grant.ExecutionGrant` β but it never runs anything. | |
| The dispatcher is the **only** path to a backend, and it refuses to run an action | |
| unless it is handed a grant that proves the gate authorized *that exact action*. | |
| The trust model in one line: | |
| No valid grant β no execution. Ever. | |
| The dispatcher never re-evaluates policy and never trusts its caller's word: it | |
| trusts only the gate's signature. Four independent checks must *all* pass before | |
| anything runs: | |
| 1. the grant's signature verifies against the gate's public key; | |
| 2. the decision is ``ALLOW``; | |
| 3. the grant has not expired (the ~60s TTL window); and | |
| 4. the grant's ``action_hash`` matches the action actually presented. | |
| Any failure raises :class:`GrantVerificationError` and the backend is never | |
| touched. On success the dispatcher routes on the action's ``backend`` field to | |
| the matching adapter and returns that adapter's result to the caller. | |
| Where the four backend adapters come from: they are supplied to the dispatcher | |
| (Phase 5 builds the real ones β Direct API, Function, MCP, Safe CLI). The | |
| dispatcher only needs each to satisfy the tiny :class:`BackendAdapter` interface, | |
| so it can route to them without knowing their internals. | |
| """ | |
| from __future__ import annotations | |
| from collections.abc import Iterator, Mapping | |
| from contextlib import contextmanager | |
| from typing import Protocol, runtime_checkable | |
| from control_plane.governance import GovernanceDecision | |
| from control_plane.grant import ExecutionGrant, GateVerifier, hash_action | |
| from control_plane.schema import Backend, ProposedAction | |
| class GrantVerificationError(Exception): | |
| """Raised when a grant is missing or fails any verification check. | |
| Raising (rather than returning a value) makes the safety guarantee | |
| impossible to ignore: a refused dispatch cannot be mistaken for a successful | |
| one, and the backend code below the check never runs. The message names the | |
| specific check that failed, which is useful both for debugging and as an | |
| audit/attack signal. | |
| """ | |
| class BackendAdapter(Protocol): | |
| """The minimal contract every execution backend must satisfy (Phase 5). | |
| The dispatcher routes a verified action to one of these and returns whatever | |
| it produces. Keeping the interface this small means the dispatcher stays | |
| decoupled from each backend's internals β it just calls ``run``. | |
| """ | |
| def run(self, action: ProposedAction) -> object: ... | |
| class Dispatcher: | |
| """Verifies a gate-signed grant, then routes the action to its backend. | |
| Holds two things: the gate's public verifier (to check grants β it can never | |
| mint one) and the table of backend adapters to route to. Construct it once | |
| and call :meth:`dispatch` per authorized action. | |
| """ | |
| def __init__( | |
| self, | |
| verifier: GateVerifier, | |
| backends: Mapping[Backend, BackendAdapter], | |
| ) -> None: | |
| # Public-key-only verifier: the dispatcher can prove a grant is genuine | |
| # but can never forge one (the private gate key never leaves the gate). | |
| self._verifier = verifier | |
| # backend enum β the adapter that executes it. Phase 5 supplies the real | |
| # adapters; tests supply spies. dict() takes a defensive copy so the | |
| # routing table can't change underneath the dispatcher after construction. | |
| self._backends: dict[Backend, BackendAdapter] = dict(backends) | |
| def dispatch( | |
| self, action: ProposedAction, grant: ExecutionGrant | None | |
| ) -> object: | |
| """Run *action* iff *grant* proves the gate authorized it; return the result. | |
| Verifies the grant first (raising :class:`GrantVerificationError` on any | |
| problem, before any backend is reached), then routes to the matching | |
| adapter and returns its result. | |
| """ | |
| # Gate first, execute second: nothing below this line runs unless the | |
| # grant passes every check. | |
| self._verify_grant(action, grant) | |
| # Grant is valid β route on the action's backend field to its adapter. | |
| adapter = self._backends.get(action.backend) | |
| if adapter is None: | |
| # The grant authorized the action, but no backend is wired for it β | |
| # a configuration gap, not a security refusal, so it's a distinct error. | |
| raise ValueError(f"No backend adapter registered for {action.backend.value!r}") | |
| return adapter.run(action) | |
| def _verify_grant( | |
| self, action: ProposedAction, grant: ExecutionGrant | None | |
| ) -> None: | |
| """Run the four grant checks; raise on any failure. | |
| Returns ``None`` when the grant is good; otherwise raises | |
| :class:`GrantVerificationError` naming the failed check. Ordered cheapest | |
| and most fundamental first (presence β authenticity β decision β | |
| freshness β binding). | |
| """ | |
| # (0) A missing grant is the simplest "no authorization" case. | |
| if grant is None: | |
| raise GrantVerificationError("no grant supplied: action is not authorized") | |
| # (1) Authenticity: the signature must verify against the gate's public | |
| # key. This is what makes a grant unforgeable β only the gate's | |
| # private key could have produced a signature this key accepts. | |
| if not self._verifier.verify(grant): | |
| raise GrantVerificationError("grant signature did not verify against the gate key") | |
| # (2) The grant must actually be an ALLOW. (The gate only ever mints | |
| # ALLOW grants, so this guards against a forged or hand-built grant.) | |
| if grant.decision is not GovernanceDecision.ALLOW: | |
| raise GrantVerificationError(f"grant decision is {grant.decision.value}, not ALLOW") | |
| # (3) Freshness: a grant is valid only inside its short TTL window, so a | |
| # leaked or replayed grant goes stale almost immediately. | |
| if grant.is_expired(): | |
| raise GrantVerificationError("grant has expired") | |
| # (4) Binding: recompute the action's fingerprint and compare. This stops | |
| # a grant minted for action A from being redeemed against action B. | |
| if grant.action_hash != hash_action(action): | |
| raise GrantVerificationError("grant is not bound to this action (action_hash mismatch)") | |
| # --------------------------------------------------------------------------- # | |
| # Canonical wiring of all four backends (Phase 5.6) # | |
| # --------------------------------------------------------------------------- # | |
| def build_default_dispatcher(verifier: GateVerifier) -> Iterator["Dispatcher"]: | |
| """Assemble a :class:`Dispatcher` wired to all four real backends. | |
| This is the single, canonical place the rest of the app gets a fully wired | |
| dispatcher. The :class:`Dispatcher` class above stays deliberately generic β it | |
| routes to whatever adapters it is handed β so the knowledge of *which* concrete | |
| backends exist lives here, in one obvious spot, rather than being scattered | |
| across the codebase. | |
| The backends are imported lazily (inside this function) on purpose: importing | |
| this module stays cheap, and the generic dispatcher keeps zero dependencies on | |
| any concrete backend, so it remains trivial to unit-test in isolation. | |
| It is a **context manager** because one backend β the MCP client β holds a live | |
| connection and a background thread that must be released. Writing | |
| ``with build_default_dispatcher(verifier) as dispatcher:`` guarantees that clean | |
| shutdown happens automatically, even if an error occurs mid-use. | |
| """ | |
| # Lazy imports keep `import dispatcher` light and the class backend-agnostic. | |
| from execution_backends.cli_executor import CliExecutorBackend | |
| from execution_backends.direct_api import DirectApiBackend | |
| from execution_backends.function_call import FunctionCallBackend | |
| from execution_backends.mcp_client import McpClientBackend | |
| # The MCP backend opens a connection + background loop at construction, so it is | |
| # the one adapter that needs explicit teardown (the other three are stateless). | |
| mcp_backend = McpClientBackend() | |
| try: | |
| # The routing table: each Backend enum value -> the adapter that performs it. | |
| # This is exactly the map Dispatcher.dispatch consults *after* it has verified | |
| # the grant, so an action's `backend` field selects its adapter here. Every | |
| # Backend enum member is present, so all four paths are reachable. | |
| yield Dispatcher( | |
| verifier, | |
| { | |
| Backend.DIRECT_API: DirectApiBackend(), | |
| Backend.FUNCTION_CALL: FunctionCallBackend(), | |
| Backend.MCP_CLIENT: mcp_backend, | |
| Backend.CLI_EXECUTOR: CliExecutorBackend(), | |
| }, | |
| ) | |
| finally: | |
| # Always release the MCP connection/thread β no orphaned resources, even on | |
| # error, because this runs on the way out of the `with` block. | |
| mcp_backend.close() | |