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()