diff --git a/.github/workflows/python-cli.yaml b/.github/workflows/python-cli.yaml new file mode 100644 index 0000000..5c861b3 --- /dev/null +++ b/.github/workflows/python-cli.yaml @@ -0,0 +1,56 @@ +name: Python CLI + +on: + workflow_dispatch: + pull_request: + paths: + - 'python/**' + - 'scripts/check_wire_contract.py' + - 'crates/engine/src/gateway/**' + - 'crates/cli/src/gateway_cmd.rs' + - '.github/workflows/python-cli.yaml' + push: + branches: [main] + paths: + - 'python/**' + - 'scripts/check_wire_contract.py' + - 'crates/engine/src/gateway/**' + - 'crates/cli/src/gateway_cmd.rs' + - '.github/workflows/python-cli.yaml' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + python-cli: + name: lint, type-check, test, wire-contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Create venv with the package and tooling + # ty resolves imports against the active venv, so the package and its + # deps (httpx, typer) must be installed there, alongside ruff/ty/pytest. + run: | + uv venv + uv pip install ./python 'ruff==0.16.5' 'ty==0.0.77' pytest httpx + + - name: Ruff format check + run: uv run ruff format --check python/ scripts/check_wire_contract.py + + - name: Ruff lint + run: uv run ruff check python/ scripts/check_wire_contract.py + + - name: Type check (ty) + run: uv run ty check python/sealg scripts/check_wire_contract.py + + - name: Tests + run: uv run pytest python/tests -q + + - name: Wire contract (Python <-> Rust clients agree) + run: python3 scripts/check_wire_contract.py diff --git a/prek.toml b/prek.toml index 05c0843..e7915de 100644 --- a/prek.toml +++ b/prek.toml @@ -48,3 +48,17 @@ name = "fail if any source folder exceeds the file-count error threshold" language = "system" entry = "scripts/check_folder_sizes.sh" files = "\\.(tsx|ts|rs)$" + +# ── sealg wire contract: Python client (python/) ↔ Rust client (crates/) ── +[[repos]] +repo = "local" + +[[repos.hooks]] +id = "wire-contract" +name = "sealg wire contract: Python <-> Rust clients agree" +language = "system" +entry = "python3 scripts/check_wire_contract.py" +pass_filenames = false +# Run when the Python contract, the Rust gateway source, or the check script +# changes. Both clients live in this repo, so the check always runs both legs. +files = "^(python/sealg/(contract|client|cli)\\.py|crates/engine/src/gateway/(config|client)\\.rs|crates/cli/src/gateway_cmd\\.rs|scripts/check_wire_contract\\.py)$" diff --git a/python/.env.example b/python/.env.example new file mode 100644 index 0000000..7f04408 --- /dev/null +++ b/python/.env.example @@ -0,0 +1,24 @@ +# SealGate gateway coordinates. Export these in your shell (or your runtime sets +# them). All config is read from the environment only. + +# Gateway origin (no /mcp suffix). Defaults to http://localhost:3000 when unset. +SEALGATE_URL=https://mcp.sealgate.ai + +# SealGate API key from https://dashboard.sealgate.ai. Optional: leave unset when +# an upstream proxy injects Authorization. When set, it is embedded in the +# /mcp/{key}/ path. +# SEALGATE_API_KEY=ew_live_... + +# Zero-knowledge secret key, only needed for tools that decrypt stored secrets. +# Generate it at https://dashboard.sealgate.ai/dashboard/settings. +# SEALGATE_SECRET_KEY=... + +# Stable conversation id for audit/trifecta continuity. Falls back to +# CENTAUR_THREAD_KEY, which some agent runtimes set automatically. +# SEALGATE_CONVERSATION_ID=... + +# CA bundle for a MITM egress proxy (first of these that is set wins), so sealg +# trusts the proxy's CA. +# SSL_CERT_FILE=/etc/ssl/proxy-ca.pem +# REQUESTS_CA_BUNDLE=/etc/ssl/proxy-ca.pem +# NODE_EXTRA_CA_CERTS=/etc/ssl/proxy-ca.pem diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..3efbdda --- /dev/null +++ b/python/README.md @@ -0,0 +1,41 @@ +# sealg (Python) + +A Python client for the SealGate gateway, exposing the same `sealg` CLI as the +Rust binary in this repo. It is a thin MCP-over-HTTP client: `sealg list` / +`sealg call` forward `tools/list` / `tools/call` to your per-user gateway +endpoint, where all policy and enforcement live. It carries no policy of its own. + +It exists alongside the Rust `sealg` because a `uvx`-installable Python package +drops into environments that reach for `uvx`/`pip` rather than a native binary. +The two clients are kept from drifting by `scripts/check_wire_contract.py` (a +pre-commit + CI check): every shared wire constant lives once in +`sealg/contract.py` and is checked against the Rust source in `crates/`. + +## Install and run + +``` +uvx --from python/ sealg doctor # from a checkout +uvx --from 'git+https://github.com/Edison-Watch/cli#subdirectory=python' sealg list +``` + +Or `pip install ./python` into a virtualenv, then run `sealg`. + +## Commands + +``` +sealg doctor # resolved gateway env + reachability probe +sealg list [--json] # tools your org has authorized +sealg call [--args '{}'] # invoke one tool +``` + +Exit codes mirror the Rust CLI: `0` ok, `1` client/transport error, `6` the +gateway returned an MCP tool error (`isError: true`). + +## Configuration + +All from the environment (see `.env.example`): `SEALGATE_URL`, +`SEALGATE_API_KEY` (optional - keyless when auth is injected upstream by a +proxy), `SEALGATE_SECRET_KEY`, `SEALGATE_CONVERSATION_ID` (with a +`CENTAUR_THREAD_KEY` fallback that some agent runtimes set automatically), and a +CA bundle via `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS` for +a MITM egress proxy. diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..71aadcc --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "sealg" +description = "Python client for the SealGate gateway - governed access to every tool via one CLI" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.27.0", + "typer>=0.12.0", +] + +# The command this package installs: `sealg list`, `sealg call ...`, `sealg doctor`. +[project.scripts] +sealg = "sealg.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["sealg"] diff --git a/python/sealg/__init__.py b/python/sealg/__init__.py new file mode 100644 index 0000000..96b70e3 --- /dev/null +++ b/python/sealg/__init__.py @@ -0,0 +1 @@ +"""SealGate CLI (sealg) - the Python client for the SealGate gateway.""" diff --git a/python/sealg/__pycache__/__init__.cpython-311.pyc b/python/sealg/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..07f5ede Binary files /dev/null and b/python/sealg/__pycache__/__init__.cpython-311.pyc differ diff --git a/python/sealg/__pycache__/client.cpython-311.pyc b/python/sealg/__pycache__/client.cpython-311.pyc new file mode 100644 index 0000000..9477bbb Binary files /dev/null and b/python/sealg/__pycache__/client.cpython-311.pyc differ diff --git a/python/sealg/__pycache__/contract.cpython-311.pyc b/python/sealg/__pycache__/contract.cpython-311.pyc new file mode 100644 index 0000000..cde917e Binary files /dev/null and b/python/sealg/__pycache__/contract.cpython-311.pyc differ diff --git a/python/sealg/cli.py b/python/sealg/cli.py new file mode 100644 index 0000000..7288522 --- /dev/null +++ b/python/sealg/cli.py @@ -0,0 +1,151 @@ +"""CLI for SealGate - a thin MCP client to the SealGate gateway. + +``sealg list`` / ``sealg call`` forward ``tools/list`` / ``tools/call`` to the +per-user gateway endpoint; all policy and enforcement live in the gateway. +``sealg doctor`` reports the resolved environment and probes reachability. This +is the Python client; it mirrors the Rust ``sealg`` binary's surface and exit +codes. +""" + +from __future__ import annotations + +import json +import os +import platform + +import typer + +from .client import GatewayClient, GatewayConfig, redact_url +from .contract import ENV_CA_BUNDLE, EXIT_ERROR, EXIT_TOOL_ERROR + +app = typer.Typer( + name="sealg", + help="Command-line interface for SealGate, the agentic data firewall", + no_args_is_help=True, + add_completion=False, +) + + +def _emit(value: object) -> None: + print(json.dumps(value, indent=2, ensure_ascii=False, default=str)) + + +@app.command("doctor") +def doctor( + gateway_url: str = typer.Option( + None, "--gateway-url", help="Override the gateway base URL." + ), +) -> None: + """Report the resolved gateway environment and probe reachability.""" + cfg = GatewayConfig.from_env(url_override=gateway_url) + report: dict[str, object] = { + "tool": "sealg", + "os": platform.system().lower(), + "arch": platform.machine(), + "gateway_url": redact_url(cfg.mcp_url(), cfg.api_key), + "auth": cfg.auth_mode(), + "secret_key_set": cfg.secret_key is not None, + "conversation_id_set": cfg.conversation_id is not None, + "ca_bundle": cfg.ca_bundle, + # Which env var actually supplied the bundle (first non-blank, matching + # GatewayConfig's precedence), or None. + "ca_bundle_source": next( + (k for k in ENV_CA_BUNDLE if os.environ.get(k, "").strip()), None + ), + } + # Separate "could we reach + initialize the gateway" (reachable) from "did + # the tools/list probe succeed" (readiness), so a connect that succeeds but + # a probe that fails isn't reported as unreachable. + try: + client = GatewayClient(cfg).connect() + except Exception as exc: # noqa: BLE001 - doctor reports failures, never raises on them + report["reachable"] = False + report["error"] = str(exc) + else: + report["reachable"] = True + try: + report["tool_count"] = len(client.tools_list()) + except Exception as exc: # noqa: BLE001 + report["probe_error"] = str(exc) + finally: + client.close() + + _emit(report) # doctor is a diagnostic; always structured JSON + if not report["reachable"]: + raise typer.Exit(EXIT_ERROR) + + +@app.command("list") +def list_tools( + json_out: bool = typer.Option( + False, "--json", help="Output as a JSON array of {name, description}." + ), + gateway_url: str = typer.Option( + None, "--gateway-url", help="Override the gateway base URL." + ), +) -> None: + """List the user's tools from the live SealGate gateway.""" + cfg = GatewayConfig.from_env(url_override=gateway_url) + try: + client = GatewayClient(cfg).connect() + try: + tools = client.tools_list() + finally: + client.close() + except Exception as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(EXIT_ERROR) from exc + + if json_out: + _emit([{"name": t.name, "description": t.description} for t in tools]) + else: + for t in tools: + # The em dash keeps this list output identical to the Rust client's; + # written as a backslash-u2014 escape because the repo ai-writing + # check bans a literal U+2014 in source. + typer.echo(f"{t.name} \u2014 {t.description}") + + +@app.command("call") +def call_tool( + tool: str = typer.Argument(..., help="Tool name as advertised by `sealg list`."), + args: str = typer.Option( + "{}", "--args", help="JSON arguments object to pass to the tool." + ), + gateway_url: str = typer.Option( + None, "--gateway-url", help="Override the gateway base URL." + ), +) -> None: + """Call a tool on the live SealGate gateway.""" + try: + arguments = json.loads(args) + except json.JSONDecodeError as exc: + typer.echo(f"error: invalid --args JSON: {exc}", err=True) + raise typer.Exit(EXIT_ERROR) from exc + # MCP arguments must be an object. Reject a valid-JSON non-object locally + # (e.g. --args '5' or '"x"') with a clear error rather than forwarding an + # invalid tools/call. null is allowed; the client maps it to {}. + if arguments is not None and not isinstance(arguments, dict): + typer.echo("error: --args must be a JSON object", err=True) + raise typer.Exit(EXIT_ERROR) + + cfg = GatewayConfig.from_env(url_override=gateway_url) + try: + client = GatewayClient(cfg).connect() + try: + result = client.tools_call(tool, arguments) + finally: + client.close() + except Exception as exc: + typer.echo(f"error: {exc}", err=True) + raise typer.Exit(EXIT_ERROR) from exc + + _emit(result) + # Mirror the MCP tool-call response: an `isError: true` result is a failed + # call and must not exit 0. + if isinstance(result, dict) and result.get("isError") is True: + raise typer.Exit(EXIT_TOOL_ERROR) + + +if __name__ == "__main__": + app() diff --git a/python/sealg/client.py b/python/sealg/client.py new file mode 100644 index 0000000..78bc0be --- /dev/null +++ b/python/sealg/client.py @@ -0,0 +1,341 @@ +"""A thin MCP-over-HTTP client to the SealGate gateway. + +A faithful Python port of the Rust ``sealg`` transport in this repo +(``crates/engine/src/gateway/``). It speaks the small slice of MCP it needs - +``initialize``, ``tools/list``, ``tools/call`` - directly to the gateway's +``/mcp/{api_key}/`` endpoint. It carries no policy: the gateway enforces access +control, lethal-trifecta blocking, and audit. All the wire constants come from +:mod:`contract` so this client and the Rust one cannot drift (the pre-commit +guard verifies it). + +Config resolves purely from the environment (matching the Rust binary), so the +CLI stays stateless and drops cleanly into any shell, CI job, or agent sandbox. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import ssl +from collections.abc import Mapping +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError, version +from typing import Any, Self + +import httpx + +from .contract import ( + ACCEPT, + CONVERSATION_ID_HEADER, + DEFAULT_URL, + ENV_API_KEY, + ENV_CA_BUNDLE, + ENV_CONVERSATION_ID, + ENV_CONVERSATION_ID_FALLBACK, + ENV_SECRET_KEY, + ENV_URL, + MCP_PATH_KEYLESS, + MCP_PATH_WITH_KEY, + PROTOCOL_VERSION, + PROTOCOL_VERSION_HEADER, + SECRET_KEY_HEADER, +) + +DEFAULT_TIMEOUT = 30.0 + + +class GatewayError(Exception): + """Any failure reaching or talking to the gateway.""" + + +class RpcError(GatewayError): + """A JSON-RPC error returned by the gateway (mirrors the MCP error shape).""" + + def __init__(self, code: int, message: str, data: object = None) -> None: + super().__init__(f"rpc error {code}: {message}") + self.code = code + self.message = message + self.data = data + + +@dataclass +class ToolInfo: + name: str + description: str + input_schema: object + + +@dataclass +class GatewayConfig: + """Everything needed to reach the gateway, resolved once from the env.""" + + base_url: str + api_key: str | None + secret_key: str | None + conversation_id: str | None + ca_bundle: str | None + + @staticmethod + def _get(env: Mapping[str, str], key: str) -> str | None: + """Return the raw env value if non-blank, else None. strip() is only the + blank test - the value itself is returned unmodified, matching the Rust + client (`.filter(|v| !v.trim().is_empty())`), so a padded key or id is + sent identically by both clients.""" + val = env.get(key) + if val is None or not val.strip(): + return None + return val + + @classmethod + def from_env( + cls, env: Mapping[str, str] | None = None, url_override: str | None = None + ) -> GatewayConfig: + env = os.environ if env is None else env + + base_url = (cls._get(env, ENV_URL) or DEFAULT_URL).rstrip("/") + if url_override is not None: + trimmed = url_override.strip().rstrip("/") + if trimmed: + base_url = trimmed + + conversation_id = cls._get(env, ENV_CONVERSATION_ID) or cls._get( + env, ENV_CONVERSATION_ID_FALLBACK + ) + ca_bundle = next((v for k in ENV_CA_BUNDLE if (v := cls._get(env, k))), None) + + return cls( + base_url=base_url, + api_key=cls._get(env, ENV_API_KEY), + secret_key=cls._get(env, ENV_SECRET_KEY), + conversation_id=conversation_id, + ca_bundle=ca_bundle, + ) + + def mcp_url(self) -> str: + """``{base}/mcp/{key}/`` with a key, else ``{base}/mcp/`` (auth injected). + + The path shapes come from :mod:`contract` (``MCP_PATH_*``) so the drift + guard can compare them against the Rust ``mcp_url``. + """ + if self.api_key: + return self.base_url + MCP_PATH_WITH_KEY.format(key=self.api_key) + return self.base_url + MCP_PATH_KEYLESS + + def auth_mode(self) -> str: + return "env-key" if self.api_key else "proxy-injected" + + +def redact_url(url: str, api_key: str | None) -> str: + """Replace the ``/{key}/`` path segment with ``/***/`` so errors don't leak + the key (it rides in the ``/mcp/{key}/`` path). Only the delimited segment + is replaced, never a blanket substring swap.""" + if api_key: + return url.replace(f"/{api_key}/", "/***/") + return url + + +def _extract_rpc_result(content_type: str, body: str, want_id: int) -> Any: + """Extract the JSON-RPC ``result`` for ``want_id`` from a JSON or SSE body. + + Raises :class:`RpcError` on a JSON-RPC error, :class:`GatewayError` on a + protocol problem. Pure, so it is unit-tested directly. + """ + if "text/event-stream" in content_type: + msg = _sse_find_response(body, want_id) + if msg is None: + raise GatewayError("no JSON-RPC response in SSE stream") + else: + try: + msg = json.loads(body.strip()) + except json.JSONDecodeError as e: + raise GatewayError(f"invalid JSON response: {e}") from e + return _rpc_message_to_result(msg) + + +def _rpc_message_to_result(msg: object) -> Any: + # A valid-JSON scalar (e.g. `5`) is a protocol error, not a crash. + if not isinstance(msg, dict): + raise GatewayError("JSON-RPC response was not an object") + if "error" in msg and msg["error"] is not None: + err = msg["error"] + if not isinstance(err, dict): + raise GatewayError("JSON-RPC error was not an object") + raise RpcError( + code=int(err.get("code", 0)), + message=str(err.get("message", "unknown error")), + data=err.get("data"), + ) + if "result" in msg: + return msg["result"] + raise GatewayError("JSON-RPC response had neither result nor error") + + +def _sse_find_response(body: str, want_id: int) -> dict | None: + """Scan SSE ``data:`` frames for the JSON-RPC response matching ``want_id``.""" + fallback: dict | None = None + for line in body.splitlines(): + line = line.lstrip() + if not line.startswith("data:"): + continue + try: + v = json.loads(line[len("data:") :].strip()) + except json.JSONDecodeError: + continue + if not isinstance(v, dict) or ("result" not in v and "error" not in v): + continue # notifications carry neither + if v.get("id") == want_id: + return v + if fallback is None: + fallback = v + return fallback + + +class GatewayClient: + """A live connection to the gateway's per-user MCP endpoint.""" + + def __init__(self, cfg: GatewayConfig, timeout: float = DEFAULT_TIMEOUT) -> None: + self.cfg = cfg + self.url = cfg.mcp_url() + _u = httpx.URL(self.url) + self._origin = (_u.scheme, _u.host, _u.port) + self._session_id: str | None = None + self._next_id = 0 + # verify: add the MITM CA to the system trust store (additive, matching + # the Rust client's add_root_certificate) rather than replacing it. A + # missing/invalid bundle surfaces as GatewayError, not a raw ssl/OSError. + verify: ssl.SSLContext | bool = True + if cfg.ca_bundle: + ctx = ssl.create_default_context() + try: + ctx.load_verify_locations(cafile=cfg.ca_bundle) + except (OSError, ssl.SSLError) as e: + raise GatewayError(f"CA bundle {cfg.ca_bundle}: {e}") from e + verify = ctx + # trust_env=True (default) honors HTTPS_PROXY/HTTP_PROXY. follow_redirects + # mirrors reqwest (the Rust client follows up to 10; cap it the same so + # redirect-exhaustion behavior matches). The request hook strips the + # credential headers on any cross-origin redirect so a redirect to a + # different host can't carry sealg's secret key or session id off the + # configured gateway origin. + self._http = httpx.Client( + timeout=timeout, + verify=verify, + follow_redirects=True, + max_redirects=10, + event_hooks={"request": [self._strip_creds_off_origin]}, + ) + + def _strip_creds_off_origin(self, request: httpx.Request) -> None: + origin = (request.url.scheme, request.url.host, request.url.port) + if origin != self._origin: + for header in (SECRET_KEY_HEADER, CONVERSATION_ID_HEADER, "Mcp-Session-Id"): + request.headers.pop(header, None) + + def __enter__(self) -> Self: + self.connect() + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def close(self) -> None: + self._http.close() + + def connect(self) -> GatewayClient: + """Run the MCP ``initialize`` handshake and capture any session id.""" + params = { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "sealg", "version": _version()}, + } + _, session_id = self._rpc_capture_session("initialize", params) + self._session_id = session_id + # Best-effort readiness notification; stateless servers may ignore it. + with contextlib.suppress(GatewayError): + self._notify("notifications/initialized", {}) + return self + + def tools_list(self) -> list[ToolInfo]: + result = self._rpc("tools/list", {}) + tools = result.get("tools") if isinstance(result, dict) else None + if not isinstance(tools, list): + raise GatewayError("tools/list missing `tools` array") + return [ + ToolInfo( + name=t.get("name", ""), + description=t.get("description", ""), + input_schema=t.get("inputSchema"), + ) + for t in tools + ] + + def tools_call(self, name: str, arguments: object) -> Any: + # Mirror the Rust client: only a null/None arguments becomes {}. + args = {} if arguments is None else arguments + return self._rpc("tools/call", {"name": name, "arguments": args}) + + # --- transport --------------------------------------------------------- + + def _bump_id(self) -> int: + self._next_id += 1 + return self._next_id + + def _headers(self) -> dict[str, str]: + h = { + "Content-Type": "application/json", + "Accept": ACCEPT, + PROTOCOL_VERSION_HEADER: PROTOCOL_VERSION, + } + if self._session_id: + h["Mcp-Session-Id"] = self._session_id + if self.cfg.secret_key: + h[SECRET_KEY_HEADER] = self.cfg.secret_key + if self.cfg.conversation_id: + h[CONVERSATION_ID_HEADER] = self.cfg.conversation_id + return h + + def _rpc(self, method: str, params: object) -> Any: + return self._rpc_capture_session(method, params)[0] + + def _rpc_capture_session( + self, method: str, params: object + ) -> tuple[Any, str | None]: + rpc_id = self._bump_id() + body = {"jsonrpc": "2.0", "id": rpc_id, "method": method, "params": params} + try: + resp = self._http.post(self.url, headers=self._headers(), json=body) + except httpx.TimeoutException as e: + raise GatewayError("timeout") from e + except httpx.HTTPError as e: + raise GatewayError( + f"POST {redact_url(self.url, self.cfg.api_key)}: {e}" + ) from e + + session_id = resp.headers.get("mcp-session-id") + # Mirror reqwest's is_success(): only 2xx carries a JSON-RPC envelope. + # Anything else (an un-followed 3xx after redirect exhaustion, an auth + # 401) is an HTTP-level failure, not a body for the JSON-RPC parser. + if not (200 <= resp.status_code < 300): + # The body may echo the /mcp/{key}/ request URL, so redact the key + # before it reaches stderr/logs. + body = redact_url(resp.text[:512], self.cfg.api_key) + raise GatewayError(f"gateway returned HTTP {resp.status_code}: {body}") + result = _extract_rpc_result( + resp.headers.get("content-type", ""), resp.text, rpc_id + ) + return result, session_id + + def _notify(self, method: str, params: object) -> None: + body = {"jsonrpc": "2.0", "method": method, "params": params} + try: + self._http.post(self.url, headers=self._headers(), json=body) + except httpx.HTTPError as e: + raise GatewayError(str(e)) from e + + +def _version() -> str: + try: + return version("sealg") + except PackageNotFoundError: + return "0.0.0" diff --git a/python/sealg/contract.py b/python/sealg/contract.py new file mode 100644 index 0000000..c607ed3 --- /dev/null +++ b/python/sealg/contract.py @@ -0,0 +1,63 @@ +"""Single source of truth for the SealGate gateway wire contract. + +This Python client and the Rust ``sealg`` binary in this repo talk to the same +MCP-over-HTTP gateway endpoint, so everything the two must agree on lives here +as plain literals. The drift guard (``scripts/check_wire_contract.py``, run on +pre-commit and in CI) loads this module in isolation and compares it against +the Rust source of truth in ``crates/engine/src/gateway/config.rs`` and +``.../client.rs``. + +Keep this module import-free and side-effect-free: the guard imports it +directly from its file path, so a stray relative import would break the check. +Do not edit a value here without changing the Rust constant it mirrors (or the +guard fails), and vice versa. +""" + +from __future__ import annotations + +# --- protocol (mirrors client.rs::PROTOCOL_VERSION) ------------------------ +# The MCP Streamable HTTP protocol version advertised in ``initialize``. Must be +# a version the gateway's MCP server accepts; it rejects unknown versions with +# JSON-RPC -32600. +PROTOCOL_VERSION = "2025-06-18" + +# --- headers (mirror config.rs SECRET_KEY_HEADER / CONVERSATION_ID_HEADER) -- +# Carries the zero-knowledge secret key value. +SECRET_KEY_HEADER = "sealgate_secret_key" +# Carries the stable conversation id (gateway: SEALGATE_CONVERSATION_ID_HEADER +# in src/middleware/session_tokens.py). +CONVERSATION_ID_HEADER = "x-sealgate-conversation-id" +# Advertises the protocol version on every request. +PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" +# The Accept value that lets the gateway answer with either a single JSON +# object or an SSE stream. +ACCEPT = "application/json, text/event-stream" + +# --- environment keys (mirror config.rs::env_keys) ------------------------- +ENV_URL = "SEALGATE_URL" +ENV_API_KEY = "SEALGATE_API_KEY" +ENV_SECRET_KEY = "SEALGATE_SECRET_KEY" +ENV_CONVERSATION_ID = "SEALGATE_CONVERSATION_ID" +# Fallback conversation-id source, set automatically by some agent runtimes. +# The Rust client honors it too; kept here for wire parity. +ENV_CONVERSATION_ID_FALLBACK = "CENTAUR_THREAD_KEY" +# CA bundle paths for a MITM egress proxy, tried in order (first set wins). +ENV_CA_BUNDLE = ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS") + +# --- defaults & path shape (mirror config.rs::DEFAULT_URL / mcp_url) -------- +# Default gateway origin when SEALGATE_URL is unset: the dev endpoint, never a +# guessed prod host. Prod deployments set the env var. +DEFAULT_URL = "http://localhost:3000" +# The MCP endpoint path. With a key it rides in the path; keyless when auth is +# injected upstream by a proxy. +MCP_PATH_WITH_KEY = "/mcp/{key}/" +MCP_PATH_KEYLESS = "/mcp/" + +# --- exit codes (mirror gateway_cmd.rs) ------------------------------------ +# Client/transport failure (bad config, network, protocol). A normal result is +# the framework default (0) and needs no constant. +EXIT_ERROR = 1 +# The gateway returned an MCP tool error (``isError: true``); kept distinct +# from EXIT_ERROR so a caller can tell a failed tool call from a connection +# failure. +EXIT_TOOL_ERROR = 6 diff --git a/python/tests/__pycache__/test_wire.cpython-311-pytest-9.1.1.pyc b/python/tests/__pycache__/test_wire.cpython-311-pytest-9.1.1.pyc new file mode 100644 index 0000000..3c55bc2 Binary files /dev/null and b/python/tests/__pycache__/test_wire.cpython-311-pytest-9.1.1.pyc differ diff --git a/python/tests/test_wire.py b/python/tests/test_wire.py new file mode 100644 index 0000000..31545b1 --- /dev/null +++ b/python/tests/test_wire.py @@ -0,0 +1,236 @@ +"""Unit tests for the SealGate Python client's wire behavior. + +Ported from the Rust client's tests (``crates/engine/src/gateway/config.rs`` and +``client.rs``) so the two implementations behave identically on the parts that +matter: config resolution, key-in-path, conversation-id fallback, CA-bundle +precedence, JSON/SSE result extraction, redirect/status handling, and URL +redaction. The wire *constants* are covered separately by +``scripts/check_wire_contract.py``; these cover the *logic*. + +Run: ``uv run --with httpx --with pytest pytest python/tests``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import httpx +import pytest + +# Import the package straight from the source dir (no install needed). +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from sealg import client +from sealg.client import GatewayConfig, GatewayError, RpcError, redact_url + +_extract = client._extract_rpc_result + + +# --- config resolution (ports config.rs tests) ----------------------------- + + +def test_defaults_when_unset(): + c = GatewayConfig.from_env(env={}) + assert c.base_url == "http://localhost:3000" + assert c.api_key is None + assert c.mcp_url() == "http://localhost:3000/mcp/" + assert c.auth_mode() == "proxy-injected" + + +def test_key_goes_in_the_path_and_trailing_slash_is_trimmed(): + c = GatewayConfig.from_env( + env={ + "SEALGATE_URL": "https://dashboard.sealgate.ai/", + "SEALGATE_API_KEY": "ew_live_abc", + } + ) + assert c.base_url == "https://dashboard.sealgate.ai" + assert c.mcp_url() == "https://dashboard.sealgate.ai/mcp/ew_live_abc/" + assert c.auth_mode() == "env-key" + + +def test_conversation_id_falls_back(): + c = GatewayConfig.from_env(env={"CENTAUR_THREAD_KEY": "slack:C123:171.45"}) + assert c.conversation_id == "slack:C123:171.45" + c = GatewayConfig.from_env( + env={"SEALGATE_CONVERSATION_ID": "explicit", "CENTAUR_THREAD_KEY": "fallback"} + ) + assert c.conversation_id == "explicit" + + +def test_ca_bundle_tries_paths_in_order(): + c = GatewayConfig.from_env(env={"REQUESTS_CA_BUNDLE": "/certs/ca.pem"}) + assert c.ca_bundle == "/certs/ca.pem" + c = GatewayConfig.from_env( + env={"SSL_CERT_FILE": "/a.pem", "REQUESTS_CA_BUNDLE": "/b.pem"} + ) + assert c.ca_bundle == "/a.pem" + + +def test_url_override_trims_and_ignores_blank(): + c = GatewayConfig.from_env(env={}, url_override="https://gw.example.com/") + assert c.base_url == "https://gw.example.com" + for degenerate in (" ", "/", "///", " // "): + c = GatewayConfig.from_env( + env={"SEALGATE_URL": "https://keep.example"}, url_override=degenerate + ) + assert c.base_url == "https://keep.example" + + +def test_blank_values_treated_as_unset(): + c = GatewayConfig.from_env(env={"SEALGATE_URL": " ", "SEALGATE_API_KEY": ""}) + assert c.base_url == "http://localhost:3000" + assert c.api_key is None + + +def test_nonblank_value_is_returned_unmodified(): + # strip() is only the blank test; a padded key/id is preserved so the + # Python and Rust requests are byte-identical. + c = GatewayConfig.from_env( + env={"SEALGATE_API_KEY": " k ", "SEALGATE_SECRET_KEY": "s\t"} + ) + assert c.api_key == " k " + assert c.secret_key == "s\t" + + +def test_cross_origin_redirect_strips_credential_headers(): + from sealg.contract import CONVERSATION_ID_HEADER, SECRET_KEY_HEADER + + cfg = GatewayConfig.from_env( + env={"SEALGATE_URL": "https://gw.test", "SEALGATE_SECRET_KEY": "s"} + ) + gc = client.GatewayClient(cfg) + try: + same = httpx.Request( + "POST", + "https://gw.test/mcp/", + headers={SECRET_KEY_HEADER: "s", "Mcp-Session-Id": "x"}, + ) + gc._strip_creds_off_origin(same) + assert same.headers.get(SECRET_KEY_HEADER) == "s" # same origin: kept + other = httpx.Request( + "POST", + "https://evil.test/mcp/", + headers={ + SECRET_KEY_HEADER: "s", + CONVERSATION_ID_HEADER: "c", + "Mcp-Session-Id": "x", + }, + ) + gc._strip_creds_off_origin(other) + assert SECRET_KEY_HEADER not in other.headers # cross-origin: stripped + assert CONVERSATION_ID_HEADER not in other.headers + assert "Mcp-Session-Id" not in other.headers + finally: + gc.close() + + +def test_scalar_json_response_is_protocol_error(): + # A valid-JSON scalar must be a GatewayError, not an uncaught TypeError. + with pytest.raises(GatewayError): + _extract("application/json", "5", 1) + + +# --- result extraction (ports client.rs tests) ----------------------------- + + +def test_plain_json_result(): + r = _extract( + "application/json", '{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}', 1 + ) + assert r["tools"] == [] + + +def test_json_rpc_error_maps_to_rpc_error(): + body = '{"jsonrpc":"2.0","id":2,"error":{"code":-32000,"message":"blocked by policy","data":{"rule":"trifecta"}}}' + with pytest.raises(RpcError) as ei: + _extract("application/json", body, 2) + assert ei.value.code == -32000 + assert ei.value.message == "blocked by policy" + assert ei.value.data == {"rule": "trifecta"} + + +def test_sse_stream_picks_matching_id(): + body = 'event: message\ndata: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}\n\n' + assert _extract("text/event-stream; charset=utf-8", body, 7)["ok"] is True + + +def test_sse_skips_notifications_and_finds_response(): + body = ( + 'data: {"jsonrpc":"2.0","method":"notifications/message","params":{}}\n' + 'data: {"jsonrpc":"2.0","id":3,"result":{"done":1}}\n' + ) + assert _extract("text/event-stream", body, 3)["done"] == 1 + + +def test_invalid_json_is_protocol_error(): + with pytest.raises(GatewayError): + _extract("application/json", "not json", 1) + + +# --- redaction (ports client.rs redact test) ------------------------------- + + +def test_redact_url_hides_the_api_key(): + out = redact_url("https://gw.example/mcp/ew_live_SECRET/", "ew_live_SECRET") + assert "ew_live_SECRET" not in out + assert out == "https://gw.example/mcp/***/" + assert redact_url("https://gw.example/mcp/", None) == "https://gw.example/mcp/" + assert ( + redact_url("https://abc.example/mcp/abc/", "abc") + == "https://abc.example/mcp/***/" + ) + + +# --- HTTP status / redirect handling (match reqwest) ----------------------- + + +def _mock_client(handler): + cfg = GatewayConfig.from_env( + env={"SEALGATE_URL": "https://gw.test", "SEALGATE_API_KEY": "k"} + ) + gc = client.GatewayClient(cfg) + gc._http.close() # close the real client __init__ opened before swapping it + gc._http = httpx.Client( + transport=httpx.MockTransport(handler), follow_redirects=True + ) + return gc + + +def test_client_follows_redirects_by_default(): + cfg = GatewayConfig.from_env(env={"SEALGATE_URL": "https://gw.test"}) + gc = client.GatewayClient(cfg) + try: + assert gc._http.follow_redirects is True + finally: + gc.close() + + +def test_non_2xx_is_http_error_not_parse_error(): + gc = _mock_client(lambda request: httpx.Response(500, text="boom")) + try: + with pytest.raises(GatewayError) as ei: + gc._rpc("tools/list", {}) + assert "HTTP 500" in str(ei.value) + finally: + gc.close() + + +def test_3xx_is_followed_to_the_result(): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/mcp/k/": + return httpx.Response(307, headers={"location": "https://gw.test/mcp/k2/"}) + return httpx.Response( + 200, json={"jsonrpc": "2.0", "id": 1, "result": {"tools": []}} + ) + + gc = _mock_client(handler) + try: + assert gc._rpc("tools/list", {}) == {"tools": []} + finally: + gc.close() + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/scripts/check_wire_contract.py b/scripts/check_wire_contract.py new file mode 100644 index 0000000..f5c7641 --- /dev/null +++ b/scripts/check_wire_contract.py @@ -0,0 +1,228 @@ +"""Fail if the SealGate wire contract drifts between the Python and Rust clients. + +This repo ships two ``sealg`` clients for the same MCP-over-HTTP gateway endpoint: + +1. The Rust binary (the canonical source of truth): ``crates/engine/src/gateway/ + config.rs``, ``.../client.rs``, and ``crates/cli/src/gateway_cmd.rs``. +2. The Python client in ``python/sealg/``, whose wire constants live in + ``contract.py``. + +If the two disagree about the protocol version, header names, env-var names, the +``/mcp/{key}/`` path shape, or the tool-error exit code, one client silently +talks to the gateway differently from the other. Because both live in this repo, +this guard reads both directly and always compares them - no cross-repo checkout. + +Run: ``python3 scripts/check_wire_contract.py`` (also the ``wire-contract`` prek +hook and the python-cli CI workflow). +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +CONTRACT_PATH = REPO_ROOT / "python" / "sealg" / "contract.py" +CONFIG_RS = REPO_ROOT / "crates" / "engine" / "src" / "gateway" / "config.rs" +CLIENT_RS = REPO_ROOT / "crates" / "engine" / "src" / "gateway" / "client.rs" +GATEWAY_CMD_RS = REPO_ROOT / "crates" / "cli" / "src" / "gateway_cmd.rs" + +# The canonical values, asserted against both clients. Editing a value here means +# the wire changed: update contract.py AND the Rust constant to match. +SNAPSHOT = { + "PROTOCOL_VERSION": "2025-06-18", + "SECRET_KEY_HEADER": "sealgate_secret_key", + "CONVERSATION_ID_HEADER": "x-sealgate-conversation-id", + "ACCEPT": "application/json, text/event-stream", + "PROTOCOL_VERSION_HEADER": "MCP-Protocol-Version", + "ENV_URL": "SEALGATE_URL", + "ENV_API_KEY": "SEALGATE_API_KEY", + "ENV_SECRET_KEY": "SEALGATE_SECRET_KEY", + "ENV_CONVERSATION_ID": "SEALGATE_CONVERSATION_ID", + "ENV_CONVERSATION_ID_FALLBACK": "CENTAUR_THREAD_KEY", + "ENV_CA_BUNDLE": ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS"), + "DEFAULT_URL": "http://localhost:3000", + "MCP_PATH_WITH_KEY": "/mcp/{key}/", + "MCP_PATH_KEYLESS": "/mcp/", + "EXIT_ERROR": 1, + "EXIT_TOOL_ERROR": 6, +} + + +def _fail(lines: list[str]) -> None: + print("sealg wire-contract drift check FAILED:\n", file=sys.stderr) + for line in lines: + print(f" - {line}", file=sys.stderr) + print( + "\nThe Python client (python/sealg/contract.py) and the Rust client " + "(crates/engine/src/gateway/) must agree. Update both, or fix the snapshot " + "in this script if the wire genuinely changed.", + file=sys.stderr, + ) + sys.exit(1) + + +def _load_contract() -> dict[str, object]: + # Exec the source text directly rather than importing it: SourceFileLoader + # caches bytecode in __pycache__ keyed on 1-second mtime granularity, so two + # edits within the same second can serve a stale .pyc and hide real drift. + if not CONTRACT_PATH.is_file(): + _fail([f"cannot find {CONTRACT_PATH}"]) + namespace: dict[str, object] = {} + exec(compile(CONTRACT_PATH.read_text(), str(CONTRACT_PATH), "exec"), namespace) # noqa: S102 + missing = [k for k in SNAPSHOT if k not in namespace] + if missing: + _fail([f"contract.py is missing constant {k}" for k in missing]) + return {k: namespace[k] for k in SNAPSHOT} + + +def _check_python(contract: dict[str, object], problems: list[str]) -> None: + for key, want in SNAPSHOT.items(): + got = contract.get(key) + if got != want: + problems.append(f"contract.py {key} = {got!r}, expected {want!r}") + + +def _rust_str_const(text: str, name: str) -> str | None: + m = re.search(rf'const\s+{re.escape(name)}\s*:\s*&str\s*=\s*"([^"]*)"', text) + return m.group(1) if m else None + + +def _rust_str_list(text: str, name: str) -> tuple[str, ...] | None: + m = re.search( + rf"const\s+{re.escape(name)}\s*:\s*&\[&str\]\s*=\s*&\[([^\]]*)\]", text + ) + if not m: + return None + return tuple(re.findall(r'"([^"]*)"', m.group(1))) + + +def _rust_int_const(text: str, name: str) -> int | None: + m = re.search(rf"const\s+{re.escape(name)}\s*:\s*i32\s*=\s*(\d+)", text) + return int(m.group(1)) if m else None + + +def _rust_mcp_path_shapes(text: str) -> tuple[str | None, str | None]: + """Extract the with-key and keyless path shapes from config.rs `mcp_url`. + + Rust builds them as ``format!("{}/mcp/{}/", base, key)`` and + ``format!("{}/mcp/", base)``. Strip the leading ``{}`` (base_url) and + normalize the key placeholder ``{}`` -> ``{key}`` so the shapes compare to + the Python constants. The two clients hitting different URLs is the worst + drift, so it gets an explicit extractor. + """ + with_key = keyless = None + for fmt in re.findall(r'format!\(\s*"([^"]*)"', text): + if not fmt.startswith("{}/mcp/"): + continue + suffix = fmt[len("{}") :] + if "{}" in suffix: + with_key = suffix.replace("{}", "{key}") + else: + keyless = suffix + return with_key, keyless + + +def _check_rust(problems: list[str]) -> None: + for path in (CONFIG_RS, CLIENT_RS, GATEWAY_CMD_RS): + if not path.is_file(): + _fail([f"cannot find Rust source {path} (repo layout moved)"]) + config = CONFIG_RS.read_text() + client = CLIENT_RS.read_text() + gateway_cmd = GATEWAY_CMD_RS.read_text() + + mcp_with_key, mcp_keyless = _rust_mcp_path_shapes(config) + + # (snapshot key, value parsed from Rust, human name of the Rust symbol) + checks: list[tuple[str, object, str]] = [ + ( + "PROTOCOL_VERSION", + _rust_str_const(client, "PROTOCOL_VERSION"), + "PROTOCOL_VERSION", + ), + ( + "SECRET_KEY_HEADER", + _rust_str_const(config, "SECRET_KEY_HEADER"), + "SECRET_KEY_HEADER", + ), + ( + "CONVERSATION_ID_HEADER", + _rust_str_const(config, "CONVERSATION_ID_HEADER"), + "CONVERSATION_ID_HEADER", + ), + ("DEFAULT_URL", _rust_str_const(config, "DEFAULT_URL"), "DEFAULT_URL"), + ("ENV_URL", _rust_str_const(config, "URL"), "env_keys::URL"), + ("ENV_API_KEY", _rust_str_const(config, "API_KEY"), "env_keys::API_KEY"), + ( + "ENV_SECRET_KEY", + _rust_str_const(config, "SECRET_KEY"), + "env_keys::SECRET_KEY", + ), + ( + "ENV_CONVERSATION_ID", + _rust_str_const(config, "CONVERSATION_ID"), + "env_keys::CONVERSATION_ID", + ), + ( + "ENV_CONVERSATION_ID_FALLBACK", + _rust_str_const(config, "CENTAUR_THREAD_KEY"), + "env_keys::CENTAUR_THREAD_KEY", + ), + ("ENV_CA_BUNDLE", _rust_str_list(config, "CA_BUNDLE"), "env_keys::CA_BUNDLE"), + ("MCP_PATH_WITH_KEY", mcp_with_key, "mcp_url (with-key format!)"), + ("MCP_PATH_KEYLESS", mcp_keyless, "mcp_url (keyless format!)"), + ( + "EXIT_TOOL_ERROR", + _rust_int_const(gateway_cmd, "EXIT_TOOL_ERROR"), + "EXIT_TOOL_ERROR", + ), + ] + for snap_key, got, rust_name in checks: + want = SNAPSHOT[snap_key] + if got is None: + problems.append(f"could not find Rust {rust_name} (parser or source moved)") + elif got != want: + problems.append( + f"Rust {rust_name} = {got!r}, expected {want!r} (contract.py {snap_key})" + ) + + # The Accept + protocol-version headers are string literals in client.rs's + # request builder. Anchor to the `.header(...)` call so a stale copy of the + # literal elsewhere (a comment, a test) can't satisfy the check. + accept = re.escape(str(SNAPSHOT["ACCEPT"])) + ver_header = re.escape(str(SNAPSHOT["PROTOCOL_VERSION_HEADER"])) + header_checks = [ + ("ACCEPT", rf'\.header\(\s*[^,]*ACCEPT\s*,\s*"{accept}"'), + ("PROTOCOL_VERSION_HEADER", rf'\.header\(\s*"{ver_header}"\s*,'), + ] + for snap_key, pattern in header_checks: + if not re.search(pattern, client): + problems.append( + f"Rust client.rs request builder no longer sets {snap_key} = " + f"{SNAPSHOT[snap_key]!r} (parser or request builder moved)" + ) + + # EXIT_ERROR (1) is the generic client/transport failure code. The Rust CLI + # exits it inline (`std::process::exit(1)`) rather than via a named const, so + # assert that literal appears in gateway_cmd.rs to keep the 0/1/6 parity that + # contract.py advertises under enforcement on both sides. + if f"exit({SNAPSHOT['EXIT_ERROR']})" not in gateway_cmd.replace(" ", ""): + problems.append( + f"Rust gateway_cmd.rs no longer exits {SNAPSHOT['EXIT_ERROR']} on client failure " + "(contract.py EXIT_ERROR)" + ) + + +def main() -> None: + problems: list[str] = [] + contract = _load_contract() + _check_python(contract, problems) + _check_rust(problems) + if problems: + _fail(problems) + print(f"sealg wire-contract OK: {len(SNAPSHOT)} constants agree [Python + Rust]") + + +if __name__ == "__main__": + main()