M4 implementada e backlog validado

This commit is contained in:
Marcos Lohmann
2026-09-14 11:22:03 -03:00
parent c894c1604b
commit 2fe6d6d508
53 changed files with 3152 additions and 39 deletions

View File

@@ -0,0 +1,114 @@
"""Modo A -- Global Supervisor externo (EP-13-T05, ADR-024, README.md §6.2).
ADR-024 defines two equally valid integration modes for a multiagent
platform sitting on top of the E-MCP-BUS:
- **Modo A (governança-somente)**: an external Global Supervisor (e.g. the
Agent Platform OCI reference, ADR-022's StateGraph/router) decides which
backend agent handles a conversation turn. Our own Service Orchestrator
(EP-11) does not participate in that decision at all.
- **Modo B (substituição completa, EP-13-T06)**: our own Service
Orchestrator computes the NBA/NBO directly, and drives handoff through
`emcp_bus.agent_runtime.handoff_graph.execute_handoff` with a real
`NBADecision` (EP-11-T03).
`GlobalSupervisor` below is Modo A's stand-in for that external platform --
deliberately simple, keyword-based routing (it is not, and must never
become, a security boundary) that never touches `emcp_bus.orchestrator` at
all (checked mechanically in `tests/unit/test_agent_runtime_global_supervisor.py`,
the same AST-import discipline `identity_resolver.py`, EP-12-T04, uses for
the same reason). This is *why* `global_supervisor.py` does not build on
`handoff_graph.execute_handoff`/`AgentDispatcher.dispatch` even though both
already exist (EP-12-T01/T03): those consume an `NBADecision`, which only
our own Orchestrator produces -- requiring one here would silently turn
Modo A into Modo B.
The actual security property Modo A must uphold (EP-13-T05's acceptance
criteria) does not live in this module at all: it lives in the fact that
each `BackendAgent` this class routes to resolves its own MCP
Client/entitlement completely independently (its own bearer token, its own
`tools/list`, EP-01/EP-05/EP-03) -- `GlobalSupervisor` never reads, stores,
or forwards one backend's credential to another. Routing to the "wrong"
backend, or a backend being tricked by a prompt into wanting to act outside
its own catalog, is always still caught downstream by the real Gateway/PDP,
exactly as for a single-agent deployment (README.md §29).
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Protocol
RouterFn = Callable[[str], "str | None"]
"""Picks a backend name from a message's content, or `None` to stay on the
current backend -- see `keyword_router` below for the reference implementation
and the module docstring for why routing quality is deliberately out of scope."""
class BackendAgent(Protocol):
"""What `GlobalSupervisor` needs from a backend agent -- structurally
typed (not `agents.example_agent.agent.ExampleAgent` directly) so this
core-library module never imports from `agents/` (example/demo code),
matching the discipline `dispatcher.py`'s `AgentTokenProvider` already
applies to credentials."""
async def run_turn(self, message: str) -> object: ...
@dataclass(frozen=True)
class RoutingResult:
backend_name: str
handed_off: bool
"""True if this turn was routed to a *different* backend than
`current_backend` -- the caller/demo UI can use this to render "you are
now speaking with billing" or similar, purely cosmetic."""
turn_result: object
class UnknownBackendError(Exception):
pass
class GlobalSupervisor:
def __init__(self, backends: dict[str, BackendAgent]) -> None:
if not backends:
raise ValueError("GlobalSupervisor needs at least one backend")
self._backends = dict(backends)
async def route_and_run(
self, message: str, *, current_backend: str, router: RouterFn
) -> RoutingResult:
"""`router` picks a backend name (or `None` to stay on
`current_backend`) from the message content alone -- a real
deployment plugs in whatever routing intelligence the external
platform provides (an LLM-based intent classifier, a rules engine,
...); this RI ships `keyword_router` below as a minimal, honest
stand-in, not a claim about routing quality."""
if current_backend not in self._backends:
raise UnknownBackendError(current_backend)
target = router(message) or current_backend
if target not in self._backends:
target = current_backend
result = await self._backends[target].run_turn(message)
return RoutingResult(
backend_name=target, handed_off=target != current_backend, turn_result=result
)
def keyword_router(message: str, *, routes: dict[str, frozenset[str]] | None = None) -> str | None:
"""Minimal reference router: the first backend whose keyword set
appears in `message` (case-insensitive), else `None` (stay put). Not
the security boundary (see module docstring) -- just enough to
demonstrate a real handoff decision without requiring a second LLM
call to make it."""
routes = routes or {
"finance_agent": frozenset({"invoice", "invoices", "pay", "payment", "bill", "billing"})
}
lowered = message.lower()
for backend_name, keywords in routes.items():
if any(keyword in lowered for keyword in keywords):
return backend_name
return None

View File

@@ -10,6 +10,7 @@ queued-and-allowed.
from __future__ import annotations
import threading
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -26,6 +27,15 @@ class ApprovalService:
def __init__(self, audit_sink: AuditSink | None = None) -> None:
self._requests: dict[str, ApprovalRequest] = {}
self._audit_sink = audit_sink
# EP-15-T04 ("approval replay" under concurrency): `check_and_consume`'s
# check-then-set (read `.status`, then write it) is not a single
# bytecode op, so without this lock two real OS threads racing the
# same granted approval could both observe "granted" before either
# writes "consumed" -- a double-spend of a single-use grant. Plain
# `threading.Lock`, not `asyncio.Lock`: this service is called from
# sync code paths (the Gateway's request handler) that may run under
# a thread pool, not only from a single asyncio event loop.
self._lock = threading.Lock()
def request(
self,
@@ -86,15 +96,16 @@ class ApprovalService:
operation_hash = compute_operation_hash(
client_profile=client_profile, tool=tool, arguments=arguments
)
for approval in self._requests.values():
if (
approval.operation_hash == operation_hash
and approval.status == "granted"
and not approval.is_expired()
):
approval.status = "consumed"
return True
return False
with self._lock:
for approval in self._requests.values():
if (
approval.operation_hash == operation_hash
and approval.status == "granted"
and not approval.is_expired()
):
approval.status = "consumed"
return True
return False
def _pending_for_operation(self, operation_hash: str) -> ApprovalRequest | None:
for approval in self._requests.values():

View File

@@ -37,6 +37,7 @@ top of that (EP-08-T03).
from __future__ import annotations
import contextlib
import os
import tempfile
from collections.abc import AsyncIterator, Awaitable, Callable
@@ -65,6 +66,7 @@ from emcp_bus.audit.correlation import (
compute_policy_decision_id,
new_decision_id,
)
from emcp_bus.audit.models import EventEnvelope
from emcp_bus.audit.sink import AuditSink, JSONLFileAuditSink
from emcp_bus.common.config import load_yaml_models
from emcp_bus.common.otel import setup_tracing
@@ -270,6 +272,19 @@ def _request_id_str(ctx: ServerRequestContext[GatewayState]) -> str | None:
return str(ctx.request_id) if ctx.request_id is not None else None
def _emit_audit(state: GatewayState, event: EventEnvelope) -> None:
"""README.md §38 ("Audit sink | buffer seguro; não bloquear low-risk
apenas se política permitir"): the PDP/approval decision has already been
made by the time any of this module's `emit()` calls run, for every
outcome (ALLOW, DENY, REQUIRE_APPROVAL) -- a sink failure (disk full,
unreachable queue) must never turn an already-decided response into an
unrelated 500 for the caller, nor silently retry into a duplicate
downstream side effect. A production sink is responsible for its own
retry/alerting; this Gateway's request/response cycle is not it (EP-15-T05)."""
with contextlib.suppress(Exception):
state.audit_sink.emit(event)
async def on_list_tools(
ctx: ServerRequestContext[GatewayState],
params: types.PaginatedRequestParams | None,
@@ -284,10 +299,11 @@ async def on_list_tools(
except UnknownClientError:
return types.ListToolsResult(tools=[])
state.audit_sink.emit(
_emit_audit(
state,
events.client_authenticated(
client_id=access_token.client_id, mcp_request_id=_request_id_str(ctx)
)
),
)
# EP-06-T01: query only the backends that actually serve a tool this
@@ -304,14 +320,15 @@ async def on_list_tools(
)
all_tools.extend(t for t in downstream_result.tools if t.name in resolved.allowed_tools)
state.audit_sink.emit(
_emit_audit(
state,
events.tools_list_filtered(
client_id=access_token.client_id,
profile_name=resolved.profile_name,
entitlement_version=resolved.entitlement_version,
allowed=len(all_tools),
total=len(resolved.allowed_tools),
)
),
)
# EP-05-T03: see cache.py's module docstring -- `ttl_ms` will not
# actually reach a client on this RI's classic-session transport (a
@@ -356,20 +373,21 @@ async def on_call_tool(
client_profile=resolved.profile_name, tool=params.name, arguments=arguments
)
except PDPUnavailableError:
state.audit_sink.emit(
events.pdp_unavailable(client_id=access_token.client_id, tool=params.name)
_emit_audit(
state, events.pdp_unavailable(client_id=access_token.client_id, tool=params.name)
)
return _denied("PDP_UNAVAILABLE")
if decision.outcome is PDPOutcome.DENY:
state.audit_sink.emit(
_emit_audit(
state,
events.tool_call_denied(
client_id=access_token.client_id,
profile_name=resolved.profile_name,
tool=params.name,
reason_code=decision.reason_code,
correlation=correlation,
)
),
)
return _denied(decision.reason_code)
@@ -390,28 +408,30 @@ async def on_call_tool(
arguments=arguments,
reason_code=decision.reason_code,
)
state.audit_sink.emit(
_emit_audit(
state,
events.tool_call_require_approval(
client_id=access_token.client_id,
profile_name=resolved.profile_name,
tool=params.name,
approval_id=request.approval_id,
correlation=correlation,
)
),
)
return _denied(f"REQUIRE_APPROVAL:{decision.reason_code}:{request.approval_id}")
try:
route = await state.router.route(params.name)
except UnroutableCapabilityError:
state.audit_sink.emit(
_emit_audit(
state,
events.tool_call_denied(
client_id=access_token.client_id,
profile_name=resolved.profile_name,
tool=params.name,
reason_code="UNROUTABLE_CAPABILITY",
correlation=correlation,
)
),
)
return _denied("UNROUTABLE_CAPABILITY")
@@ -419,10 +439,11 @@ async def on_call_tool(
# EP-06-T04: a "rest"/"grpc" backend.type has no seed capability yet
# (see fabric/adapters/rest_adapter.py's module docstring) -- fail
# closed rather than guess at a dispatch path with no test coverage.
state.audit_sink.emit(
_emit_audit(
state,
events.backend_credential_unavailable(
client_id=access_token.client_id, tool=params.name, backend=route.backend_id
)
),
)
return _denied(f"UNSUPPORTED_BACKEND_TYPE:{route.backend_type}")
@@ -432,13 +453,14 @@ async def on_call_tool(
lambda session: session.call_tool(params.name, params.arguments or {}),
)
if isinstance(result, types.CallToolResult):
state.audit_sink.emit(
_emit_audit(
state,
events.tool_call_allowed(
client_id=access_token.client_id,
profile_name=resolved.profile_name,
tool=params.name,
correlation=correlation,
)
),
)
return result
raise TypeError(f"Unexpected downstream result type for tools/call: {type(result)!r}")