Spaces:
Sleeping
Sleeping
File size: 9,303 Bytes
fbe9dad ebd50f7 fbe9dad ebd50f7 fbe9dad ebd50f7 fbe9dad ebd50f7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | """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()
|