Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/ctrlrun/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from .policy import Decision, Policy
from .receipt import Event, EventSink, JSONLEventSink, Receipt
from .state import InMemoryStateStore, SQLiteStateStore, StateStore
from .webhook import WebhookApprovalProvider

__all__ = [
"Action",
Expand Down Expand Up @@ -66,6 +67,7 @@
"ScriptedApprovalProvider",
"StateStore",
"Suspended",
"WebhookApprovalProvider",
"action_hash",
"canonicalize",
"context",
Expand Down
21 changes: 21 additions & 0 deletions src/ctrlrun/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,19 @@ def _approval_dict(record: ApprovalRecord) -> dict[str, Any]:
@click.option("--max-body-bytes", type=int, default=1024 * 1024, show_default=True)
@click.option("--allow-origin", "allow_origins", multiple=True, help="Repeatable.")
@click.option("--allow-remote", is_flag=True, help="Permit a non-loopback --listen.")
@click.option("--public-url", default=None, help="Where the gateway is reachable, for respond_to.")
@click.option("--webhook-url", default=None, help="Notify this endpoint on APPROVAL_REQUESTED.")
@click.option(
"--webhook-secret-file",
default=None,
type=click.Path(exists=True, dir_okay=False),
help="Read the shared secret from here instead of $CTRLRUN_WEBHOOK_SECRET.",
)
@click.option(
"--allow-insecure-webhook",
is_flag=True,
help="Permit an http:// webhook url, loopback only.",
)
def gateway(
upstream: str,
alias: str,
Expand All @@ -474,6 +487,10 @@ def gateway(
max_body_bytes: int,
allow_origins: tuple[str, ...],
allow_remote: bool,
public_url: str | None,
webhook_url: str | None,
webhook_secret_file: str | None,
allow_insecure_webhook: bool,
) -> None:
"""Front an MCP server, applying this directory's policy to every tools/call."""
host, _, port = listen.rpartition(":")
Expand All @@ -496,6 +513,10 @@ def gateway(
max_body_bytes=max_body_bytes,
allow_origins=tuple(allow_origins),
allow_remote=allow_remote,
public_url=public_url,
webhook_url=webhook_url,
webhook_secret_file=webhook_secret_file,
allow_insecure_webhook=allow_insecure_webhook,
)
except CTRLRunError as exc:
raise _fail(exc) from exc
Expand Down
24 changes: 23 additions & 1 deletion src/ctrlrun/gateway/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,32 @@ def serve(*, upstream: str, alias: str, **options: Any) -> None:
"""
http_client()
from ..control import Control
from ..webhook import WebhookApprovalProvider, webhook_secret
from .server import Gateway, GatewayConfig, httpx_forwarder, serve_forever

config = GatewayConfig(upstream=upstream, alias=alias, **options)
public_url = options.pop("public_url", None)
webhook_url = options.pop("webhook_url", None)
secret_file = options.pop("webhook_secret_file", None)
allow_insecure = bool(options.pop("allow_insecure_webhook", False))
secret = webhook_secret(secret_file) if secret_file else webhook_secret()

config = GatewayConfig(upstream=upstream, alias=alias, webhook_secret=secret, **options)
control = Control.from_file()
if webhook_url:
# §7 — the provider notifies; the gateway's endpoint receives. Both need the same
# secret, and neither takes it from a command-line argument (§7.3).
control = Control(
control.policy,
control.store,
WebhookApprovalProvider(
control.store,
url=webhook_url,
secret=secret,
public_url=public_url,
allow_insecure=allow_insecure,
),
sinks=[],
)
forwarder = httpx_forwarder(config)
try:
serve_forever(Gateway(config, control, forwarder))
Expand Down
28 changes: 28 additions & 0 deletions src/ctrlrun/gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import threading
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any, Final, Protocol, TypeAlias

Expand Down Expand Up @@ -61,6 +62,7 @@

#: Reserved for the webhook approval endpoint (§7.2). The MCP path may not overlap it.
CTRLRUN_PREFIX: Final = "/ctrlrun/"
APPROVALS_PATH: Final = "/ctrlrun/approvals/"

DEFAULT_UPSTREAM_TIMEOUT: Final = 30.0
DEFAULT_APPROVAL_TIMEOUT: Final = 900.0
Expand Down Expand Up @@ -151,6 +153,8 @@ class GatewayConfig:
allow_remote: bool = False
elicitation_timeout: float = DEFAULT_ELICITATION_TIMEOUT
max_elicitation_rounds: int = DEFAULT_MAX_ELICITATION_ROUNDS
webhook_secret: str | None = None
replay_window: float = 300.0

def __post_init__(self) -> None:
import re
Expand Down Expand Up @@ -234,6 +238,30 @@ def config(self) -> GatewayConfig:

# --- the request path ---------------------------------------------------------------

def handle_approval(
self, request_id: str, body: bytes, headers: Mapping[str, str]
) -> _Response:
"""`POST /ctrlrun/approvals/<request_id>` (SPEC-v0.2 §7.2).

The gateway serves it because it is the one server this release ships. Everything the
endpoint decides lives in `webhook.handle_inbound`, which is core: this is the socket
and nothing else.
"""
from ..webhook import SIGNATURE_HEADER, handle_inbound

if self._config.webhook_secret is None:
_LOG.warning("an inbound approval arrived but no webhook secret is configured")
return _Response(404)
status, message = handle_inbound(
self._control.store,
request_id,
body,
_header(headers, SIGNATURE_HEADER) or "",
secret=self._config.webhook_secret,
replay_window=timedelta(seconds=self._config.replay_window),
)
return _json(status, {"status": "ok" if status == 200 else "refused", "detail": message})

def handle(self, body: bytes, headers: Mapping[str, str]) -> _Response:
"""Decide one POST. Returns what the client gets, and records what happened."""
origin = _header(headers, "origin")
Expand Down
Loading
Loading