"""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. """ @runtime_checkable 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) # # --------------------------------------------------------------------------- # @contextmanager 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()