Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb1776a858 | |||
| a24fe0264d | |||
| 105538d3a6 | |||
| ffda40abae | |||
| 7dcce2ff12 | |||
| 31a7efc0ed | |||
| a25ea7c188 | |||
| 3dbf1780b4 |
@@ -16,7 +16,7 @@ import typing
|
|||||||
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=import-error
|
||||||
|
|
||||||
from bot_bottle.constants import IDENTITY_HEADER
|
from bot_bottle.constants import IDENTITY_HEADER
|
||||||
from bot_bottle.gateway.egress.dlp_detectors import redact_tokens, strip_crlf
|
from bot_bottle.gateway.egress.dlp_detectors import redact_tokens
|
||||||
from bot_bottle.gateway.egress.dlp_config import (
|
from bot_bottle.gateway.egress.dlp_config import (
|
||||||
DEFAULT_OUTBOUND_ON_MATCH,
|
DEFAULT_OUTBOUND_ON_MATCH,
|
||||||
ON_MATCH_BLOCK,
|
ON_MATCH_BLOCK,
|
||||||
@@ -25,19 +25,19 @@ from bot_bottle.gateway.egress.dlp_config import (
|
|||||||
from bot_bottle.gateway.egress.context import resolve_client_context
|
from bot_bottle.gateway.egress.context import resolve_client_context
|
||||||
from bot_bottle.gateway.egress.dlp import (
|
from bot_bottle.gateway.egress.dlp import (
|
||||||
build_inbound_scan_text,
|
build_inbound_scan_text,
|
||||||
build_outbound_scan_text,
|
|
||||||
build_token_allow_payload,
|
build_token_allow_payload,
|
||||||
outbound_scan_headers,
|
|
||||||
scan_inbound,
|
scan_inbound,
|
||||||
scan_outbound,
|
scan_outbound,
|
||||||
)
|
)
|
||||||
|
from bot_bottle.gateway.egress.outbound_pipeline import redact_request, scan_request
|
||||||
from bot_bottle.gateway.egress.matching import (
|
from bot_bottle.gateway.egress.matching import (
|
||||||
decide,
|
decide,
|
||||||
decide_git_fetch,
|
|
||||||
is_git_fetch_request,
|
|
||||||
is_git_push_request,
|
|
||||||
match_route,
|
match_route,
|
||||||
)
|
)
|
||||||
|
from bot_bottle.gateway.egress.request_pipeline import (
|
||||||
|
evaluate_route_policy,
|
||||||
|
git_block_reason,
|
||||||
|
)
|
||||||
from bot_bottle.gateway.egress.schema import route_to_yaml_dict
|
from bot_bottle.gateway.egress.schema import route_to_yaml_dict
|
||||||
from bot_bottle.gateway.egress.types import (
|
from bot_bottle.gateway.egress.types import (
|
||||||
LOG_BLOCKS,
|
LOG_BLOCKS,
|
||||||
@@ -435,21 +435,12 @@ class EgressAddon:
|
|||||||
request_path: str, query: str,
|
request_path: str, query: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Apply the HTTPS Git push/fetch boundary before general routing."""
|
"""Apply the HTTPS Git push/fetch boundary before general routing."""
|
||||||
if is_git_push_request(request_path, query):
|
reason = git_block_reason(
|
||||||
self._block(
|
config.routes, flow.request.pretty_host, request_path, query,
|
||||||
flow,
|
)
|
||||||
"egress: git push over HTTPS is not supported; "
|
if not reason:
|
||||||
"use the bottle.git SSH path (gitleaks-scanned by "
|
|
||||||
"git-gate's pre-receive hook).",
|
|
||||||
ctx=self._req_ctx(flow),
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
if not is_git_fetch_request(request_path, query):
|
|
||||||
return True
|
return True
|
||||||
git_decision = decide_git_fetch(config.routes, flow.request.pretty_host)
|
self._block(flow, reason, ctx=self._req_ctx(flow))
|
||||||
if git_decision.action != "block":
|
|
||||||
return True
|
|
||||||
self._block(flow, git_decision.reason, ctx=self._req_ctx(flow))
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _apply_route_policy(
|
def _apply_route_policy(
|
||||||
@@ -461,30 +452,26 @@ class EgressAddon:
|
|||||||
# are caught above; the route may inject gateway-owned auth below.
|
# are caught above; the route may inject gateway-owned auth below.
|
||||||
# Routes with preserve_auth=True pass the header through as-is so the
|
# Routes with preserve_auth=True pass the header through as-is so the
|
||||||
# agent's own credentials (e.g. registry bearer tokens) reach the upstream.
|
# agent's own credentials (e.g. registry bearer tokens) reach the upstream.
|
||||||
if route is None or not route.preserve_auth:
|
result = evaluate_route_policy(
|
||||||
|
config,
|
||||||
|
route,
|
||||||
|
host=flow.request.pretty_host,
|
||||||
|
request_path=request_path,
|
||||||
|
method=flow.request.method,
|
||||||
|
headers=dict(flow.request.headers),
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
if result.strip_authorization:
|
||||||
flow.request.headers.pop("authorization", None)
|
flow.request.headers.pop("authorization", None)
|
||||||
|
|
||||||
# Build headers mapping for match evaluation
|
if result.block_reason:
|
||||||
req_headers = {k.lower(): v for k, v in flow.request.headers.items()}
|
self._block(flow, result.block_reason, ctx=self._req_ctx(flow))
|
||||||
|
|
||||||
decision = decide(
|
|
||||||
config.routes,
|
|
||||||
flow.request.pretty_host,
|
|
||||||
request_path,
|
|
||||||
env,
|
|
||||||
request_method=flow.request.method,
|
|
||||||
request_headers=req_headers,
|
|
||||||
deny_reason=config.deny_reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
if decision.action == "block":
|
|
||||||
self._block(flow, decision.reason, ctx=self._req_ctx(flow))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if decision.inject_authorization is not None:
|
if result.inject_authorization is not None:
|
||||||
flow.request.headers["authorization"] = decision.inject_authorization
|
flow.request.headers["authorization"] = result.inject_authorization
|
||||||
|
|
||||||
if config.log >= LOG_FULL:
|
if result.log_request:
|
||||||
self._log_request(flow, env)
|
self._log_request(flow, env)
|
||||||
|
|
||||||
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
|
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
|
||||||
@@ -508,20 +495,12 @@ class EgressAddon:
|
|||||||
Loops so the supervise policy can re-scan after each approval — a
|
Loops so the supervise policy can re-scan after each approval — a
|
||||||
second, un-approved token in the same request is still caught."""
|
second, un-approved token in the same request is still caught."""
|
||||||
while True:
|
while True:
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
request_path, _, _ = flow.request.path.partition("?")
|
||||||
body = flow.request.get_text(strict=False) or ""
|
result = scan_request(
|
||||||
headers = outbound_scan_headers(route, dict(flow.request.headers))
|
flow.request,
|
||||||
scan_text = build_outbound_scan_text(
|
route,
|
||||||
flow.request.pretty_host, request_path, query, headers, body,
|
env,
|
||||||
)
|
safe_tokens=self._safe_tokens_for(slug),
|
||||||
# CRLF is scanned only over the request line + headers, never the
|
|
||||||
# body (see scan_outbound) — a body is not an injection vector.
|
|
||||||
crlf_text = build_outbound_scan_text(
|
|
||||||
flow.request.pretty_host, request_path, query, headers, "",
|
|
||||||
)
|
|
||||||
result = scan_outbound(
|
|
||||||
route, scan_text, env,
|
|
||||||
safe_tokens=self._safe_tokens_for(slug), crlf_text=crlf_text,
|
|
||||||
)
|
)
|
||||||
if result is None or result.severity != "block":
|
if result is None or result.severity != "block":
|
||||||
return True
|
return True
|
||||||
@@ -531,7 +510,7 @@ class EgressAddon:
|
|||||||
# redact scrubs every detection (tokens and structural CRLF) and
|
# redact scrubs every detection (tokens and structural CRLF) and
|
||||||
# forwards; it fails closed only if a match survives the scrub.
|
# forwards; it fails closed only if a match survives the scrub.
|
||||||
if policy == ON_MATCH_REDACT:
|
if policy == ON_MATCH_REDACT:
|
||||||
if self._redact_outbound(flow, route, env):
|
if redact_request(flow.request, route, env):
|
||||||
if self._flow_log(flow) >= LOG_BLOCKS:
|
if self._flow_log(flow) >= LOG_BLOCKS:
|
||||||
sys.stderr.write(json.dumps({
|
sys.stderr.write(json.dumps({
|
||||||
"event": "egress_redacted",
|
"event": "egress_redacted",
|
||||||
@@ -564,41 +543,6 @@ class EgressAddon:
|
|||||||
return False # _supervise_token_block wrote the 403 response
|
return False # _supervise_token_block wrote the 403 response
|
||||||
# loop: the approved value is now in safe_tokens; re-scan.
|
# loop: the approved value is now in safe_tokens; re-scan.
|
||||||
|
|
||||||
def _redact_outbound(
|
|
||||||
self, flow: http.HTTPFlow, route: Route, env: "typing.Mapping[str, str]",
|
|
||||||
) -> bool:
|
|
||||||
"""Scrub detected tokens (and CRLF injection sequences) from the mutable
|
|
||||||
request surfaces (body, headers, path/query) and re-scan. `env` is the
|
|
||||||
per-bottle env overlay. Returns True if the request is now clean; False
|
|
||||||
if a block-severity match remains on a surface redaction cannot rewrite
|
|
||||||
(the hostname) so the caller fails closed."""
|
|
||||||
body = flow.request.get_text(strict=False)
|
|
||||||
if body:
|
|
||||||
redacted_body = redact_tokens(body, env=env)
|
|
||||||
if redacted_body != body:
|
|
||||||
flow.request.text = redacted_body
|
|
||||||
for name, value in list(flow.request.headers.items()):
|
|
||||||
if name.lower() == "host":
|
|
||||||
continue # routing-critical; never a legitimate token
|
|
||||||
redacted = strip_crlf(redact_tokens(value, env=env))
|
|
||||||
if redacted != value:
|
|
||||||
flow.request.headers[name] = redacted
|
|
||||||
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=env))
|
|
||||||
if redacted_path != flow.request.path:
|
|
||||||
flow.request.path = redacted_path
|
|
||||||
|
|
||||||
request_path, _, query = flow.request.path.partition("?")
|
|
||||||
new_body = flow.request.get_text(strict=False) or ""
|
|
||||||
headers = outbound_scan_headers(route, dict(flow.request.headers))
|
|
||||||
scan_text = build_outbound_scan_text(
|
|
||||||
flow.request.pretty_host, request_path, query, headers, new_body,
|
|
||||||
)
|
|
||||||
crlf_text = build_outbound_scan_text(
|
|
||||||
flow.request.pretty_host, request_path, query, headers, "",
|
|
||||||
)
|
|
||||||
result = scan_outbound(route, scan_text, env, crlf_text=crlf_text)
|
|
||||||
return result is None or result.severity != "block"
|
|
||||||
|
|
||||||
async def _supervise_token_block(
|
async def _supervise_token_block(
|
||||||
self,
|
self,
|
||||||
flow: http.HTTPFlow,
|
flow: http.HTTPFlow,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Outbound DLP request scanning and redaction for the egress pipeline."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import ItemsView, Mapping, Protocol
|
||||||
|
|
||||||
|
from .dlp import (
|
||||||
|
build_outbound_scan_text,
|
||||||
|
outbound_scan_headers,
|
||||||
|
scan_outbound,
|
||||||
|
)
|
||||||
|
from .dlp_detectors import redact_tokens, strip_crlf
|
||||||
|
from .types import Route, ScanResult
|
||||||
|
|
||||||
|
|
||||||
|
class MutableHeaders(Protocol):
|
||||||
|
def items(self) -> ItemsView[str, str]: ...
|
||||||
|
def __getitem__(self, name: str, /) -> str: ...
|
||||||
|
def __setitem__(self, name: str, value: str, /) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class MutableRequest(Protocol):
|
||||||
|
pretty_host: str
|
||||||
|
path: str
|
||||||
|
headers: MutableHeaders
|
||||||
|
text: str
|
||||||
|
|
||||||
|
def get_text(self, strict: bool = False) -> str | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def scan_request(
|
||||||
|
request: MutableRequest,
|
||||||
|
route: Route,
|
||||||
|
env: Mapping[str, str],
|
||||||
|
*,
|
||||||
|
safe_tokens: set[str] | None = None,
|
||||||
|
) -> ScanResult | None:
|
||||||
|
"""Scan all mutable outbound request surfaces in their canonical order."""
|
||||||
|
request_path, _, query = request.path.partition("?")
|
||||||
|
headers = outbound_scan_headers(route, dict(request.headers.items()))
|
||||||
|
body = request.get_text(strict=False) or ""
|
||||||
|
scan_text = build_outbound_scan_text(
|
||||||
|
request.pretty_host, request_path, query, headers, body,
|
||||||
|
)
|
||||||
|
# Bodies cannot alter HTTP framing, so CRLF detection is deliberately
|
||||||
|
# restricted to the request line and headers.
|
||||||
|
crlf_text = build_outbound_scan_text(
|
||||||
|
request.pretty_host, request_path, query, headers, "",
|
||||||
|
)
|
||||||
|
return scan_outbound(
|
||||||
|
route,
|
||||||
|
scan_text,
|
||||||
|
env,
|
||||||
|
safe_tokens=safe_tokens,
|
||||||
|
crlf_text=crlf_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_request(
|
||||||
|
request: MutableRequest,
|
||||||
|
route: Route,
|
||||||
|
env: Mapping[str, str],
|
||||||
|
) -> bool:
|
||||||
|
"""Redact mutable request surfaces and return whether the result is clean."""
|
||||||
|
body = request.get_text(strict=False)
|
||||||
|
if body:
|
||||||
|
redacted_body = redact_tokens(body, env=env)
|
||||||
|
if redacted_body != body:
|
||||||
|
request.text = redacted_body
|
||||||
|
for name, value in list(request.headers.items()):
|
||||||
|
if name.lower() == "host":
|
||||||
|
continue
|
||||||
|
redacted = strip_crlf(redact_tokens(value, env=env))
|
||||||
|
if redacted != value:
|
||||||
|
request.headers[name] = redacted
|
||||||
|
redacted_path = strip_crlf(redact_tokens(request.path, env=env))
|
||||||
|
if redacted_path != request.path:
|
||||||
|
request.path = redacted_path
|
||||||
|
result = scan_request(request, route, env)
|
||||||
|
return result is None or result.severity != "block"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["MutableRequest", "redact_request", "scan_request"]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Framework-neutral request policy stages for the egress adapter.
|
||||||
|
|
||||||
|
The mitmproxy addon owns flow mutation and response construction. This module
|
||||||
|
owns the ordered Git and route-policy decisions so those rules remain directly
|
||||||
|
testable without a live proxy flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
|
from .matching import (
|
||||||
|
decide,
|
||||||
|
decide_git_fetch,
|
||||||
|
is_git_fetch_request,
|
||||||
|
is_git_push_request,
|
||||||
|
)
|
||||||
|
from .types import LOG_FULL, Config, Route
|
||||||
|
|
||||||
|
GIT_PUSH_BLOCK_REASON = (
|
||||||
|
"egress: git push over HTTPS is not supported; "
|
||||||
|
"use the bottle.git SSH path (gitleaks-scanned by "
|
||||||
|
"git-gate's pre-receive hook)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RoutePolicyResult:
|
||||||
|
"""The flow mutations and outcome produced by general route policy."""
|
||||||
|
|
||||||
|
block_reason: str = ""
|
||||||
|
strip_authorization: bool = False
|
||||||
|
inject_authorization: str | None = None
|
||||||
|
log_request: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def git_block_reason(
|
||||||
|
routes: Sequence[Route],
|
||||||
|
host: str,
|
||||||
|
request_path: str,
|
||||||
|
query: str,
|
||||||
|
) -> str:
|
||||||
|
"""Return the HTTPS Git policy denial, or ``""`` when allowed."""
|
||||||
|
if is_git_push_request(request_path, query):
|
||||||
|
return GIT_PUSH_BLOCK_REASON
|
||||||
|
if not is_git_fetch_request(request_path, query):
|
||||||
|
return ""
|
||||||
|
decision = decide_git_fetch(routes, host)
|
||||||
|
return decision.reason if decision.action == "block" else ""
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_route_policy(
|
||||||
|
config: Config,
|
||||||
|
route: Route | None,
|
||||||
|
*,
|
||||||
|
host: str,
|
||||||
|
request_path: str,
|
||||||
|
method: str,
|
||||||
|
headers: Mapping[str, str],
|
||||||
|
env: Mapping[str, str],
|
||||||
|
) -> RoutePolicyResult:
|
||||||
|
"""Evaluate authorization stripping, matching, injection, and logging."""
|
||||||
|
strip_authorization = route is None or not route.preserve_auth
|
||||||
|
effective_headers = {
|
||||||
|
name.lower(): value
|
||||||
|
for name, value in headers.items()
|
||||||
|
if not (strip_authorization and name.lower() == "authorization")
|
||||||
|
}
|
||||||
|
decision = decide(
|
||||||
|
config.routes,
|
||||||
|
host,
|
||||||
|
request_path,
|
||||||
|
env,
|
||||||
|
request_method=method,
|
||||||
|
request_headers=effective_headers,
|
||||||
|
deny_reason=config.deny_reason,
|
||||||
|
)
|
||||||
|
return RoutePolicyResult(
|
||||||
|
block_reason=decision.reason if decision.action == "block" else "",
|
||||||
|
strip_authorization=strip_authorization,
|
||||||
|
inject_authorization=decision.inject_authorization,
|
||||||
|
log_request=config.log >= LOG_FULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GIT_PUSH_BLOCK_REASON",
|
||||||
|
"RoutePolicyResult",
|
||||||
|
"evaluate_route_policy",
|
||||||
|
"git_block_reason",
|
||||||
|
]
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Framework-neutral MCP method and tool dispatch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Protocol
|
||||||
|
|
||||||
|
from bot_bottle.gateway.egress.context import resolve_client_context
|
||||||
|
from bot_bottle.gateway.egress.schema import route_to_yaml_dict
|
||||||
|
from bot_bottle.gateway.policy_resolver import PolicyResolver
|
||||||
|
from bot_bottle.supervisor import types as _sv
|
||||||
|
|
||||||
|
|
||||||
|
class Request(Protocol):
|
||||||
|
@property
|
||||||
|
def method(self) -> str: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def params(self) -> dict[str, object]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class MethodNotFoundError(Exception):
|
||||||
|
"""Raised when a JSON-RPC method has no MCP handler."""
|
||||||
|
|
||||||
|
|
||||||
|
Handler = Callable[[dict[str, object]], object]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Handlers:
|
||||||
|
initialize: Handler
|
||||||
|
tools_list: Handler
|
||||||
|
list_routes: Handler
|
||||||
|
check_proposal: Handler
|
||||||
|
propose: Handler
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(request: Request, handlers: Handlers) -> object:
|
||||||
|
"""Route one parsed request without depending on the HTTP server."""
|
||||||
|
if request.method == "initialize":
|
||||||
|
return handlers.initialize(request.params)
|
||||||
|
if request.method == "notifications/initialized":
|
||||||
|
return None
|
||||||
|
if request.method == "tools/list":
|
||||||
|
return handlers.tools_list(request.params)
|
||||||
|
if request.method != "tools/call":
|
||||||
|
raise MethodNotFoundError(request.method)
|
||||||
|
|
||||||
|
tool = request.params.get("name")
|
||||||
|
if tool == _sv.TOOL_LIST_EGRESS_ROUTES:
|
||||||
|
return handlers.list_routes(request.params)
|
||||||
|
if tool == _sv.TOOL_CHECK_PROPOSAL:
|
||||||
|
return handlers.check_proposal(request.params)
|
||||||
|
return handlers.propose(request.params)
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_routes_payload(
|
||||||
|
resolver: PolicyResolver,
|
||||||
|
source_ip: str,
|
||||||
|
identity_token: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Render the calling bottle's routes, failing closed to an empty list."""
|
||||||
|
config, _slug, _tokens = resolve_client_context(
|
||||||
|
resolver, source_ip, identity_token,
|
||||||
|
)
|
||||||
|
body = json.dumps(
|
||||||
|
{"routes": [route_to_yaml_dict(route) for route in config.routes]},
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
return {"content": [{"type": "text", "text": body}], "isError": False}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Handlers",
|
||||||
|
"MethodNotFoundError",
|
||||||
|
"dispatch",
|
||||||
|
"resolved_routes_payload",
|
||||||
|
]
|
||||||
@@ -58,10 +58,15 @@ import typing
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from bot_bottle.constants import IDENTITY_HEADER
|
from bot_bottle.constants import IDENTITY_HEADER
|
||||||
from bot_bottle.gateway.egress.context import resolve_client_context
|
from bot_bottle.gateway.egress.schema import load_config
|
||||||
from bot_bottle.gateway.egress.schema import load_config, route_to_yaml_dict
|
|
||||||
from bot_bottle.gateway.egress.types import LOG_OFF
|
from bot_bottle.gateway.egress.types import LOG_OFF
|
||||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
|
from bot_bottle.gateway.supervisor.mcp_dispatch import (
|
||||||
|
Handlers as DispatchHandlers,
|
||||||
|
MethodNotFoundError,
|
||||||
|
dispatch,
|
||||||
|
resolved_routes_payload,
|
||||||
|
)
|
||||||
from bot_bottle.supervisor import types as _sv
|
from bot_bottle.supervisor import types as _sv
|
||||||
|
|
||||||
|
|
||||||
@@ -611,6 +616,11 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
result = self._dispatch(req, config)
|
result = self._dispatch(req, config)
|
||||||
|
except MethodNotFoundError as e:
|
||||||
|
self._write_jsonrpc(
|
||||||
|
jsonrpc_error(req.id, ERR_METHOD_NOT_FOUND, f"method not found: {e}"),
|
||||||
|
)
|
||||||
|
return
|
||||||
except _RpcClientError as e:
|
except _RpcClientError as e:
|
||||||
self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message))
|
self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message))
|
||||||
return
|
return
|
||||||
@@ -633,41 +643,37 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self._write_jsonrpc(jsonrpc_result(req.id, result))
|
self._write_jsonrpc(jsonrpc_result(req.id, result))
|
||||||
|
|
||||||
def _dispatch(self, req: JsonRpcRequest, config: ServerConfig) -> object:
|
def _dispatch(self, req: JsonRpcRequest, config: ServerConfig) -> object:
|
||||||
method = req.method
|
def check(params: dict[str, object]) -> object:
|
||||||
if method == "initialize":
|
return handle_check_proposal(
|
||||||
return handle_initialize(req.params)
|
params,
|
||||||
if method == "notifications/initialized":
|
resolver=self._resolver_or_fail(),
|
||||||
return None # ack-only
|
source_ip=self.client_address[0],
|
||||||
if method == "tools/list":
|
identity_token=self._identity_token(),
|
||||||
return handle_tools_list(req.params)
|
|
||||||
if method == "tools/call":
|
|
||||||
# `list-egress-routes` is read-only introspection. The shared gateway
|
|
||||||
# has no static route table (routes are resolved per request by
|
|
||||||
# source IP), so answer it from the calling bottle's resolved policy.
|
|
||||||
# Otherwise the agent sees an empty allowlist and composes an egress
|
|
||||||
# proposal that *replaces* the live routes instead of extending them
|
|
||||||
# — silently dropping base routes like api.anthropic.com on approval.
|
|
||||||
if req.params.get("name") == _sv.TOOL_LIST_EGRESS_ROUTES:
|
|
||||||
return self._resolved_routes_payload()
|
|
||||||
resolver = self._resolver_or_fail()
|
|
||||||
source_ip = self.client_address[0]
|
|
||||||
token = self._identity_token()
|
|
||||||
# `check-proposal` is a non-blocking read of the calling bottle's
|
|
||||||
# own queue — attributed by (source_ip, identity_token) like a
|
|
||||||
# proposal, but it never queues or blocks.
|
|
||||||
if req.params.get("name") == _sv.TOOL_CHECK_PROPOSAL:
|
|
||||||
return handle_check_proposal(
|
|
||||||
req.params, resolver=resolver,
|
|
||||||
source_ip=source_ip, identity_token=token,
|
|
||||||
)
|
|
||||||
# The control plane attributes the proposal to the source-IP + token
|
|
||||||
# resolved bottle, so the one shared queue holds each bottle's
|
|
||||||
# proposal under its own id — no slug is asserted by this daemon.
|
|
||||||
return handle_tools_call(
|
|
||||||
req.params, config, resolver=resolver,
|
|
||||||
source_ip=source_ip, identity_token=token,
|
|
||||||
)
|
)
|
||||||
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
|
|
||||||
|
def propose(params: dict[str, object]) -> object:
|
||||||
|
return handle_tools_call(
|
||||||
|
params,
|
||||||
|
config,
|
||||||
|
resolver=self._resolver_or_fail(),
|
||||||
|
source_ip=self.client_address[0],
|
||||||
|
identity_token=self._identity_token(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return dispatch(
|
||||||
|
req,
|
||||||
|
DispatchHandlers(
|
||||||
|
initialize=handle_initialize,
|
||||||
|
tools_list=handle_tools_list,
|
||||||
|
list_routes=lambda _params: resolved_routes_payload(
|
||||||
|
self._resolver_or_fail(),
|
||||||
|
self.client_address[0],
|
||||||
|
self._identity_token(),
|
||||||
|
),
|
||||||
|
check_proposal=check,
|
||||||
|
propose=propose,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def _identity_token(self) -> str:
|
def _identity_token(self) -> str:
|
||||||
"""The agent's per-bottle identity token from the request header (the
|
"""The agent's per-bottle identity token from the request header (the
|
||||||
@@ -686,20 +692,6 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
raise _RpcInternalError("supervise server has no policy resolver")
|
raise _RpcInternalError("supervise server has no policy resolver")
|
||||||
return resolver
|
return resolver
|
||||||
|
|
||||||
def _resolved_routes_payload(self) -> dict[str, object]:
|
|
||||||
"""The calling bottle's live egress routes as the `list-egress-routes`
|
|
||||||
JSON payload, resolved by (source_ip, identity token). Fail-closed: an
|
|
||||||
unattributed source or an unreachable orchestrator yields an empty route
|
|
||||||
list (never another bottle's), courtesy of `resolve_client_context`."""
|
|
||||||
resolver = self._resolver_or_fail()
|
|
||||||
conf, _slug, _tokens = resolve_client_context(
|
|
||||||
resolver, self.client_address[0], self._identity_token(),
|
|
||||||
)
|
|
||||||
body = json.dumps(
|
|
||||||
{"routes": [route_to_yaml_dict(r) for r in conf.routes]}, indent=2,
|
|
||||||
)
|
|
||||||
return {"content": [{"type": "text", "text": body}], "isError": False}
|
|
||||||
|
|
||||||
def _write_jsonrpc(self, body: bytes) -> None:
|
def _write_jsonrpc(self, body: bytes) -> None:
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ resource-consuming boundary revalidate the assumptions it acts on. This
|
|||||||
finishes the focused quality work begun under #444 without broad rewrites:
|
finishes the focused quality work begun under #444 without broad rewrites:
|
||||||
cleanup cannot act on stale identities, policy introspection cannot publish a
|
cleanup cannot act on stale identities, policy introspection cannot publish a
|
||||||
fabricated empty policy, gateway servers bound untrusted work, and daemon
|
fabricated empty policy, gateway servers bound untrusted work, and daemon
|
||||||
shutdown does not emit uncaught background-thread failures.
|
shutdown does not emit uncaught background-thread failures. Shared
|
||||||
|
control-plane storage and gateway credential provisioning also enforce their
|
||||||
|
filesystem security contract before sensitive data is written.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
@@ -54,6 +56,9 @@ misleading behavior:
|
|||||||
11. Git smart-HTTP can retain sixteen 100 MiB request bodies concurrently,
|
11. Git smart-HTTP can retain sixteen 100 MiB request bodies concurrently,
|
||||||
cleanup mutations have no subprocess deadline, and Firecracker signalling
|
cleanup mutations have no subprocess deadline, and Firecracker signalling
|
||||||
failures bypass shared mutation accounting.
|
failures bypass shared mutation accounting.
|
||||||
|
12. SQLite creates the shared control-plane database before its mode is
|
||||||
|
restricted, then suppresses permission-repair failures. Gateway transports
|
||||||
|
also differ in whether copied deploy-key modes are preserved.
|
||||||
|
|
||||||
These are one design problem: state used to authorize deletion, replacement,
|
These are one design problem: state used to authorize deletion, replacement,
|
||||||
or resource allocation must be authoritative at the point of use.
|
or resource allocation must be authoritative at the point of use.
|
||||||
@@ -91,6 +96,11 @@ or resource allocation must be authoritative at the point of use.
|
|||||||
- Git request bodies spool to disk behind a separate heavy-work semaphore;
|
- Git request bodies spool to disk behind a separate heavy-work semaphore;
|
||||||
cleanup commands have configurable deadlines; Firecracker signalling
|
cleanup commands have configurable deadlines; Firecracker signalling
|
||||||
failures aggregate while identity-verification uncertainty still aborts.
|
failures aggregate while identity-verification uncertainty still aborts.
|
||||||
|
- The shared database directory and file are private before SQLite writes any
|
||||||
|
control-plane state; an inability to enforce those modes aborts startup.
|
||||||
|
- Gateway credential directories and files receive explicit private modes
|
||||||
|
inside the gateway, independent of Docker, Apple Container, or SSH copy
|
||||||
|
semantics.
|
||||||
- Unit tests cover PID/path reuse, partial backend enumeration, transient
|
- Unit tests cover PID/path reuse, partial backend enumeration, transient
|
||||||
policy resolution failure, slow bodies, concurrency saturation, and stream
|
policy resolution failure, slow bodies, concurrency saturation, and stream
|
||||||
closure races.
|
closure races.
|
||||||
@@ -153,6 +163,17 @@ The gateway output pump catches only stream-closure exceptions expected after
|
|||||||
the supervisor closes child pipes. Other I/O failures remain visible and are
|
the supervisor closes child pipes. Other I/O failures remain visible and are
|
||||||
reported through the supervisor's normal diagnostic channel.
|
reported through the supervisor's normal diagnostic channel.
|
||||||
|
|
||||||
|
### Shared filesystem security
|
||||||
|
|
||||||
|
The common SQLite store owns database creation for every backend. It creates
|
||||||
|
the parent directory and an empty database with private modes before opening
|
||||||
|
SQLite, repairs existing modes, verifies the resulting state, and propagates
|
||||||
|
every enforcement failure. Backend launchers do not duplicate this policy.
|
||||||
|
|
||||||
|
The backend-neutral gateway provisioner likewise applies directory and file
|
||||||
|
modes after transport copies complete. This avoids relying on copy behavior
|
||||||
|
that differs among Docker, Apple Container, and Firecracker's SSH transport.
|
||||||
|
|
||||||
## Implementation chunks
|
## Implementation chunks
|
||||||
|
|
||||||
1. Existing fail-closed security and backend enumeration fixes.
|
1. Existing fail-closed security and backend enumeration fixes.
|
||||||
@@ -168,6 +189,8 @@ reported through the supervisor's normal diagnostic channel.
|
|||||||
and mutation accounting, and contained Git backend process failures.
|
and mutation accounting, and contained Git backend process failures.
|
||||||
10. Disk-spooled and separately bounded Git bodies, cleanup command deadlines,
|
10. Disk-spooled and separately bounded Git bodies, cleanup command deadlines,
|
||||||
and classified Firecracker signalling failures.
|
and classified Firecracker signalling failures.
|
||||||
|
11. Fail-closed shared database creation and backend-neutral gateway credential
|
||||||
|
permissions.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Unit tests for framework-neutral outbound DLP request stages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from bot_bottle.gateway.egress.outbound_pipeline import (
|
||||||
|
MutableHeaders,
|
||||||
|
redact_request,
|
||||||
|
scan_request,
|
||||||
|
)
|
||||||
|
from bot_bottle.gateway.egress.types import Route
|
||||||
|
|
||||||
|
|
||||||
|
class _Headers(dict[str, str]):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Request:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
host: str = "api.example.com",
|
||||||
|
path: str = "/v1/messages",
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
body: str = "",
|
||||||
|
) -> None:
|
||||||
|
self.pretty_host = host
|
||||||
|
self.path = path
|
||||||
|
self.headers: MutableHeaders = _Headers(headers or {})
|
||||||
|
self.text = body
|
||||||
|
|
||||||
|
def get_text(self, strict: bool = False) -> str | None:
|
||||||
|
del strict
|
||||||
|
return self.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutboundScan(unittest.TestCase):
|
||||||
|
def test_detects_secret_in_body(self) -> None:
|
||||||
|
request = _Request(body="token=sk-" + "a" * 48)
|
||||||
|
|
||||||
|
result = scan_request(request, Route(host="api.example.com"), {})
|
||||||
|
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
self.assertEqual("block", result.severity if result else None)
|
||||||
|
|
||||||
|
def test_safe_token_is_ignored(self) -> None:
|
||||||
|
token = "sk-" + "a" * 48
|
||||||
|
request = _Request(body=f"token={token}")
|
||||||
|
|
||||||
|
result = scan_request(
|
||||||
|
request,
|
||||||
|
Route(host="api.example.com"),
|
||||||
|
{},
|
||||||
|
safe_tokens={token},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOutboundRedaction(unittest.TestCase):
|
||||||
|
def test_redacts_body_header_and_path_but_preserves_host(self) -> None:
|
||||||
|
token = "sk-" + "a" * 48
|
||||||
|
request = _Request(
|
||||||
|
path=f"/v1/messages?key={token}",
|
||||||
|
headers={"Host": "api.example.com", "X-Token": token + "\r\nInjected: yes"},
|
||||||
|
body=f"token={token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
clean = redact_request(request, Route(host="api.example.com"), {})
|
||||||
|
|
||||||
|
self.assertTrue(clean)
|
||||||
|
self.assertNotIn(token, request.path)
|
||||||
|
self.assertNotIn(token, request.headers["X-Token"])
|
||||||
|
self.assertNotIn("\r", request.headers["X-Token"])
|
||||||
|
self.assertNotIn(token, request.text)
|
||||||
|
self.assertEqual("api.example.com", request.headers["Host"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Unit tests for framework-neutral egress request policy stages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from bot_bottle.gateway.egress.request_pipeline import (
|
||||||
|
GIT_PUSH_BLOCK_REASON,
|
||||||
|
evaluate_route_policy,
|
||||||
|
git_block_reason,
|
||||||
|
)
|
||||||
|
from bot_bottle.gateway.egress.types import Config, LOG_FULL, Route
|
||||||
|
|
||||||
|
|
||||||
|
class TestGitPolicy(unittest.TestCase):
|
||||||
|
def test_push_is_always_blocked(self) -> None:
|
||||||
|
reason = git_block_reason(
|
||||||
|
(), "git.example.com", "/repo.git/git-receive-pack", "",
|
||||||
|
)
|
||||||
|
self.assertEqual(GIT_PUSH_BLOCK_REASON, reason)
|
||||||
|
|
||||||
|
def test_fetch_requires_route_opt_in(self) -> None:
|
||||||
|
path = "/repo.git/git-upload-pack"
|
||||||
|
blocked = git_block_reason((), "git.example.com", path, "")
|
||||||
|
allowed = git_block_reason(
|
||||||
|
(Route(host="git.example.com", git_fetch=True),),
|
||||||
|
"git.example.com",
|
||||||
|
path,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
self.assertTrue(blocked)
|
||||||
|
self.assertEqual("", allowed)
|
||||||
|
|
||||||
|
def test_non_git_request_is_not_decided_here(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
"",
|
||||||
|
git_block_reason((), "api.example.com", "/v1/messages", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutePolicy(unittest.TestCase):
|
||||||
|
def test_strips_agent_auth_and_injects_gateway_auth(self) -> None:
|
||||||
|
route = Route(
|
||||||
|
host="api.example.com",
|
||||||
|
auth_scheme="Bearer",
|
||||||
|
token_env="API_TOKEN",
|
||||||
|
)
|
||||||
|
result = evaluate_route_policy(
|
||||||
|
Config(routes=(route,)),
|
||||||
|
route,
|
||||||
|
host="api.example.com",
|
||||||
|
request_path="/v1/messages",
|
||||||
|
method="POST",
|
||||||
|
headers={"Authorization": "agent-secret"},
|
||||||
|
env={"API_TOKEN": "gateway-secret"},
|
||||||
|
)
|
||||||
|
self.assertTrue(result.strip_authorization)
|
||||||
|
self.assertEqual("Bearer gateway-secret", result.inject_authorization)
|
||||||
|
self.assertFalse(result.block_reason)
|
||||||
|
|
||||||
|
def test_preserved_auth_participates_in_matching(self) -> None:
|
||||||
|
route = Route(host="registry.example.com", preserve_auth=True)
|
||||||
|
result = evaluate_route_policy(
|
||||||
|
Config(routes=(route,), log=LOG_FULL),
|
||||||
|
route,
|
||||||
|
host="registry.example.com",
|
||||||
|
request_path="/v2/",
|
||||||
|
method="GET",
|
||||||
|
headers={"Authorization": "Bearer agent-token"},
|
||||||
|
env={},
|
||||||
|
)
|
||||||
|
self.assertFalse(result.strip_authorization)
|
||||||
|
self.assertTrue(result.log_request)
|
||||||
|
|
||||||
|
def test_missing_route_fails_closed(self) -> None:
|
||||||
|
result = evaluate_route_policy(
|
||||||
|
Config(routes=(), deny_reason="not allowed"),
|
||||||
|
None,
|
||||||
|
host="blocked.example.com",
|
||||||
|
request_path="/",
|
||||||
|
method="GET",
|
||||||
|
headers={},
|
||||||
|
env={},
|
||||||
|
)
|
||||||
|
self.assertEqual("not allowed", result.block_reason)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -13,6 +13,7 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import types
|
import types
|
||||||
|
import typing
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ from bot_bottle.gateway.supervisor.server import (
|
|||||||
jsonrpc_error,
|
jsonrpc_error,
|
||||||
jsonrpc_result,
|
jsonrpc_result,
|
||||||
parse_jsonrpc,
|
parse_jsonrpc,
|
||||||
|
resolved_routes_payload,
|
||||||
validate_proposed_file,
|
validate_proposed_file,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -701,9 +703,14 @@ class TestResolvedRoutesPayload(unittest.TestCase):
|
|||||||
" - host: api.anthropic.com\n"
|
" - host: api.anthropic.com\n"
|
||||||
" - host: www.google.com\n"
|
" - host: www.google.com\n"
|
||||||
)
|
)
|
||||||
payload = _handler(
|
payload = resolved_routes_payload(
|
||||||
_FakeSuperviseResolver(bottle_id="b1", policy=policy)
|
typing.cast(
|
||||||
)._resolved_routes_payload()
|
supervise_server.PolicyResolver,
|
||||||
|
_FakeSuperviseResolver(bottle_id="b1", policy=policy),
|
||||||
|
),
|
||||||
|
_SRC,
|
||||||
|
_TOK,
|
||||||
|
)
|
||||||
assert payload is not None
|
assert payload is not None
|
||||||
self.assertFalse(payload["isError"]) # type: ignore[index]
|
self.assertFalse(payload["isError"]) # type: ignore[index]
|
||||||
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
|
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
|
||||||
@@ -713,9 +720,14 @@ class TestResolvedRoutesPayload(unittest.TestCase):
|
|||||||
def test_orchestrator_error_fails_closed_to_empty(self) -> None:
|
def test_orchestrator_error_fails_closed_to_empty(self) -> None:
|
||||||
# resolve_client_context swallows resolver errors → deny-all (empty),
|
# resolve_client_context swallows resolver errors → deny-all (empty),
|
||||||
# never another bottle's routes.
|
# never another bottle's routes.
|
||||||
payload = _handler(
|
payload = resolved_routes_payload(
|
||||||
_FakeSuperviseResolver(raises=True)
|
typing.cast(
|
||||||
)._resolved_routes_payload()
|
supervise_server.PolicyResolver,
|
||||||
|
_FakeSuperviseResolver(raises=True),
|
||||||
|
),
|
||||||
|
_SRC,
|
||||||
|
_TOK,
|
||||||
|
)
|
||||||
assert payload is not None
|
assert payload is not None
|
||||||
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
|
data = json.loads(payload["content"][0]["text"]) # type: ignore[index]
|
||||||
self.assertEqual([], data["routes"])
|
self.assertEqual([], data["routes"])
|
||||||
@@ -724,7 +736,13 @@ class TestResolvedRoutesPayload(unittest.TestCase):
|
|||||||
# A server without a resolver is a misconfig, not a mode: raise rather
|
# A server without a resolver is a misconfig, not a mode: raise rather
|
||||||
# than list anything.
|
# than list anything.
|
||||||
with self.assertRaises(_RpcInternalError):
|
with self.assertRaises(_RpcInternalError):
|
||||||
_handler(None)._resolved_routes_payload()
|
_handler(None)._dispatch(
|
||||||
|
parse_jsonrpc(
|
||||||
|
b'{"jsonrpc":"2.0","id":1,"method":"tools/call",'
|
||||||
|
b'"params":{"name":"list-egress-routes"}}',
|
||||||
|
),
|
||||||
|
ServerConfig(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestNonBlockingSupervise(unittest.TestCase):
|
class TestNonBlockingSupervise(unittest.TestCase):
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Unit tests for framework-neutral supervisor MCP dispatch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from bot_bottle.gateway.supervisor.mcp_dispatch import (
|
||||||
|
Handlers,
|
||||||
|
MethodNotFoundError,
|
||||||
|
dispatch,
|
||||||
|
)
|
||||||
|
from bot_bottle.supervisor import types as _sv
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Request:
|
||||||
|
method: str
|
||||||
|
params: dict[str, object]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDispatch(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def handler(name: str):
|
||||||
|
def call(_params: dict[str, object]) -> str:
|
||||||
|
self.calls.append(name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
return call
|
||||||
|
|
||||||
|
self.handlers = Handlers(
|
||||||
|
initialize=handler("initialize"),
|
||||||
|
tools_list=handler("tools_list"),
|
||||||
|
list_routes=handler("list_routes"),
|
||||||
|
check_proposal=handler("check_proposal"),
|
||||||
|
propose=handler("propose"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def request(self, method: str, **params: object) -> _Request:
|
||||||
|
return _Request(method=method, params=params)
|
||||||
|
|
||||||
|
def test_routes_protocol_methods(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
"initialize", dispatch(self.request("initialize"), self.handlers),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"tools_list", dispatch(self.request("tools/list"), self.handlers),
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
dispatch(self.request("notifications/initialized"), self.handlers),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_routes_each_tool_class(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
"list_routes",
|
||||||
|
dispatch(
|
||||||
|
self.request("tools/call", name=_sv.TOOL_LIST_EGRESS_ROUTES),
|
||||||
|
self.handlers,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"check_proposal",
|
||||||
|
dispatch(
|
||||||
|
self.request("tools/call", name=_sv.TOOL_CHECK_PROPOSAL),
|
||||||
|
self.handlers,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"propose",
|
||||||
|
dispatch(self.request("tools/call", name=_sv.TOOL_EGRESS_ALLOW), self.handlers),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_method_is_typed(self) -> None:
|
||||||
|
with self.assertRaisesRegex(MethodNotFoundError, "unknown"):
|
||||||
|
dispatch(self.request("unknown"), self.handlers)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user