edangx100's picture
Update app and control-plane modules to latest
fbe9dad
Raw
History Blame Contribute Delete
9.3 kB
"""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()