diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7970039..9c65689 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -19,7 +19,7 @@ "source": "git-subdir", "url": "https://github.com/vaaraio/vaara.git", "path": "plugins/claude-code-vaara-governance", - "ref": "v0.44.0" + "ref": "v0.45.0" }, "homepage": "https://vaara.io" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e59417a..fc90de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.45.0] - 2026-05-30 + +**Theme: reach remote MCP upstreams over HTTP, and make the proxy's Streamable HTTP handling conform to the spec.** + +### Added +- `--upstream-url NAME=URL` on `vaara-mcp-proxy`: front a remote MCP server + over Streamable HTTP instead of a local stdio subprocess. A bare + `--upstream-url URL` lands under the `default` slot. The connector speaks the + 2025-03-26 and 2025-06-18 protocol revisions: it POSTs JSON-RPC and reads + either `application/json` or `text/event-stream` replies, captures and echoes + the `Mcp-Session-Id`, sends the negotiated `MCP-Protocol-Version`, and holds a + standing GET SSE channel for server-initiated notifications with + `Last-Event-ID` resume and bounded reconnect. Built on the standard-library + `urllib` only, so the zero-dependency core is preserved (httpx is not a + dependency; only fastapi and uvicorn ship behind the `server` extra). The + deprecated 2024-11-05 two-endpoint transport and interactive OAuth are out of + scope; remote auth is static-header only. +- `--upstream-header NAME=HEADER` on `vaara-mcp-proxy`: attach a static request + header such as a bearer token to a URL upstream. The header name splits on the + first `=`, so a base64 token's trailing `=` survives. Startup rejects headers + aimed at an unknown slot and stdio/url slot-name collisions. +- In-repo SEP-2787 attestation conformance vectors at + `tests/vectors/sep2787_attestation_v0/`: pinned HS256, ES256, and RS256 keys + and six cases (`hs256_digest_identity`, `es256_projection_identity`, + `rs256_signature_ttl_only`, `neg_bad_signature`, `neg_expired`, + `neg_args_mismatch`) spanning the signature, TTL, and args-commitment + dimensions, with a standard-library-only independent checker that imports no + Vaara code, a generator script, and a pytest cross-check against the library + verifier. `docs/sep2787-conformance.md` now points at these in-repo vectors + rather than a planned follow-up. + +### Fixed +- Streamable HTTP conformance in the proxy's HTTP transport. The `Mcp-Session-Id` + is now validated as visible ASCII (0x21 to 0x7E) on both POST and GET alongside + the existing 128-character cap. The `MCP-Protocol-Version` header is read and + validated against the supported set (`2025-03-26`, `2025-06-18`); an absent + header is treated as `2025-03-26`, an unsupported value returns 400. The POST + `Accept` header must offer both `application/json` and `text/event-stream`; the + check is wildcard-aware, so `*/*` and an absent header still pass and existing + clients are not broken, and a violation returns 406. + ## [0.44.0] - 2026-05-30 **Theme: a runnable reference verifier. Generate the attestation key, then verify attestations and receipts offline from the command line.** diff --git a/clients/ts/package.json b/clients/ts/package.json index ce1f049..bb3c259 100644 --- a/clients/ts/package.json +++ b/clients/ts/package.json @@ -1,6 +1,6 @@ { "name": "@vaara/client", - "version": "0.44.0", + "version": "0.45.0", "mcpName": "io.github.vaaraio/vaara", "description": "TypeScript client for the Vaara HTTP API. Conformal risk scoring, hash-chained audit, policy reload, named detectors.", "main": "dist/index.js", diff --git a/docs/sep2787-conformance.md b/docs/sep2787-conformance.md index 5082cd7..3a99326 100644 --- a/docs/sep2787-conformance.md +++ b/docs/sep2787-conformance.md @@ -88,11 +88,14 @@ null`. `tests/vectors/execution_receipt_v0/` (five cases, pinned keys, a stdlib-only `_check_independent.py` walker that verifies them without importing Vaara). -- SEP-2787 attestation vectors are published on the fork PR - `modelcontextprotocol/modelcontextprotocol#2789`. Mirroring them in-repo as - `tests/vectors/sep2787_attestation_v0/` is a planned follow-up so the - `vaara attest verify` command can be exercised against pinned fixtures the - same way the receipt vectors are. +- SEP-2787 attestation vectors live in-repo at + `tests/vectors/sep2787_attestation_v0/` (six cases across HS256/ES256/RS256, + pinned keys, a stdlib-only `_check_independent.py` walker that verifies + signature, TTL, and the step-5 argument commitment without importing Vaara). + They mirror the proposed-shape vectors on the fork PR + `modelcontextprotocol/modelcontextprotocol#2789` so the `vaara attest verify` + command can be exercised against pinned fixtures the same way the receipt + vectors are. ## Quick start diff --git a/pyproject.toml b/pyproject.toml index 98f35d2..8ddbd24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vaara" -version = "0.44.0" +version = "0.45.0" description = "Tamper-evident runtime evidence layer for AI agents: risk scoring, audit trails, and regulatory compliance" requires-python = ">=3.10" license = "Apache-2.0" diff --git a/scripts/generate_sep2787_attestation_vectors.py b/scripts/generate_sep2787_attestation_vectors.py new file mode 100644 index 0000000..7dac90a --- /dev/null +++ b/scripts/generate_sep2787_attestation_vectors.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Generate the v0 SEP-2787 attestation conformance vectors. + +Writes pinned keys and signed fixtures under +``tests/vectors/sep2787_attestation_v0/``. Run once and commit the +output; re-running regenerates the asymmetric keys and signatures. A +second implementation reads the committed fixtures with +``_check_independent.py`` and must reproduce the same canonical bytes +and verification verdicts. + +The receipt vectors (``tests/vectors/execution_receipt_v0/``) cover the +post-execution sibling. These cover the attestation itself: the three +verification dimensions ``verify_attestation`` owns (signature, TTL) plus +the step-5 argument commitment exposed as ``verify_args_commitment``. + +TTL is evaluated at a fixed instant, ``EVAL_NOW_ISO`` below, which the +independent checker pins too. The expired case carries an older ``iat`` +so its deadline falls before that instant while every other case stays +inside its window. + +Usage: python scripts/generate_sep2787_attestation_vectors.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +from vaara.attestation.sep2787 import ( + PayloadDerived, + PlannerDeclared, + ToolCallBinding, + emit_attestation, + make_args_digest, + make_args_projection, +) + +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / "tests" / "vectors" / "sep2787_attestation_v0" +HS_SECRET = bytes.fromhex("42" * 32) +IAT = "2026-05-29T10:00:00Z" +OLD_IAT = "2026-05-29T08:00:00Z" +# The independent checker evaluates TTL at this instant. 30s past IAT, +# well inside the 300s default window; the expired case (OLD_IAT, two +# hours earlier) is already past its deadline here. +EVAL_NOW_ISO = "2026-05-29T10:00:30Z" + +ARGS = {"path": "/archive/2024-Q3.md", "recursive": False} +OTHER_ARGS = {"path": "/keep/forever.md", "recursive": False} +COMMON = dict(iss="issuer://test", sub="agent:archiver", secret_version="v1") + + +def _write(path: Path, obj) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(obj, indent=2, sort_keys=True) + "\n") + + +def _attest(*, alg, signing_material, args, nonce, iat=IAT): + payload = PayloadDerived(tool_calls=(ToolCallBinding( + name="delete_file", + server_fingerprint="sha256:" + "1" * 64, + args=args, + ),)) + return emit_attestation( + planner_declared=PlannerDeclared(intent="archive obsolete report"), + payload_derived=payload, + alg=alg, signing_material=signing_material, + nonce=nonce, iat=iat, **COMMON, + ) + + +def _emit_keys() -> dict: + es = ec.generate_private_key(ec.SECP256R1()) + rs = rsa.generate_private_key(public_exponent=65537, key_size=2048) + keys = OUT / "keys" + keys.mkdir(parents=True, exist_ok=True) + (keys / "hs256_secret.bin").write_bytes(HS_SECRET) + (keys / "es256_private.pem").write_bytes(es.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) + (keys / "es256_public.pem").write_bytes(es.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + )) + (keys / "rs256_private.pem").write_bytes(rs.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) + (keys / "rs256_public.pem").write_bytes(rs.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + )) + return {"ES256": es, "RS256": rs} + + +def _case(name: str, *, attestation_dict: dict, expected: dict, + runtime_args=None) -> None: + d = OUT / "normative" / name + _write(d / "attestation.json", attestation_dict) + _write(d / "expected.json", expected) + if runtime_args is not None: + _write(d / "runtime_args.json", runtime_args) + + +def main() -> None: + asym = _emit_keys() + + # Positive: HS256, hash-only-identity commitment, runtime args in hand. + att = _attest(alg="HS256", signing_material=HS_SECRET, + args=make_args_digest(ARGS), nonce="att-nonce-hs-0001") + _case("hs256_digest_identity", attestation_dict=att.to_dict(), + runtime_args=ARGS, + expected={"signature_ok": True, "ttl_ok": True, + "args_commitment_ok": True, "projection_match": True}) + + # Positive: ES256, identity projection of the full args. + att = _attest(alg="ES256", signing_material=asym["ES256"], + args=make_args_projection(ARGS), nonce="att-nonce-es-0002") + _case("es256_projection_identity", attestation_dict=att.to_dict(), + runtime_args=ARGS, + expected={"signature_ok": True, "ttl_ok": True, + "args_commitment_ok": True, "projection_match": True}) + + # Positive: RS256, signature + TTL only. No runtime args supplied, so + # the argument-commitment step is not composed (verify_attestation + # covers steps 1 and 3; step 5 is the caller's, run once the runtime + # arguments are in hand). args_commitment_ok is null. + att = _attest(alg="RS256", signing_material=asym["RS256"], + args=make_args_projection(ARGS), nonce="att-nonce-rs-0003") + _case("rs256_signature_ttl_only", attestation_dict=att.to_dict(), + expected={"signature_ok": True, "ttl_ok": True, + "args_commitment_ok": None, "projection_match": None}) + + # Negative: signature tampered. The signed body is unchanged, so TTL + # and the argument commitment still hold; only the signature fails. + att = _attest(alg="HS256", signing_material=HS_SECRET, + args=make_args_digest(ARGS), nonce="att-nonce-hs-0004") + bad = att.to_dict() + last = bad["signature"][-1] + bad["signature"] = bad["signature"][:-1] + ("0" if last != "0" else "1") + _case("neg_bad_signature", attestation_dict=bad, runtime_args=ARGS, + expected={"signature_ok": False, "ttl_ok": True, + "args_commitment_ok": True, "projection_match": True}) + + # Negative: signature valid but the envelope is past its TTL deadline + # at EVAL_NOW (older iat, default 300s window). + att = _attest(alg="ES256", signing_material=asym["ES256"], + args=make_args_projection(ARGS), nonce="att-nonce-es-0005", + iat=OLD_IAT) + _case("neg_expired", attestation_dict=att.to_dict(), runtime_args=ARGS, + expected={"signature_ok": True, "ttl_ok": False, + "args_commitment_ok": True, "projection_match": True}) + + # Negative: signature and TTL valid, but the hash-only-identity + # commitment binds ARGS while the runtime arguments are OTHER_ARGS. + att = _attest(alg="HS256", signing_material=HS_SECRET, + args=make_args_digest(ARGS), nonce="att-nonce-hs-0006") + _case("neg_args_mismatch", attestation_dict=att.to_dict(), + runtime_args=OTHER_ARGS, + expected={"signature_ok": True, "ttl_ok": True, + "args_commitment_ok": False, "projection_match": None}) + + print(f"wrote vectors under {OUT}") + print(f"TTL evaluated at {EVAL_NOW_ISO}") + + +if __name__ == "__main__": + main() diff --git a/server-vaara-server.json b/server-vaara-server.json index ea4be6b..a2eeb5f 100644 --- a/server-vaara-server.json +++ b/server-vaara-server.json @@ -8,13 +8,13 @@ "url": "https://github.com/vaaraio/vaara", "source": "github" }, - "version": "0.44.0", + "version": "0.45.0", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "vaara", - "version": "0.44.0", + "version": "0.45.0", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/server.json b/server.json index 7e1a257..48f27d4 100644 --- a/server.json +++ b/server.json @@ -8,13 +8,13 @@ "url": "https://github.com/vaaraio/vaara", "source": "github" }, - "version": "0.44.0", + "version": "0.45.0", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "vaara", - "version": "0.44.0", + "version": "0.45.0", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/src/vaara/__init__.py b/src/vaara/__init__.py index 3c538fa..cff2d93 100644 --- a/src/vaara/__init__.py +++ b/src/vaara/__init__.py @@ -6,7 +6,7 @@ oversight. """ -__version__ = "0.44.0" +__version__ = "0.45.0" from vaara.pipeline import InterceptionPipeline, InterceptionResult diff --git a/src/vaara/integrations/_mcp_upstream.py b/src/vaara/integrations/_mcp_upstream.py index da44d87..fbb5ef9 100644 --- a/src/vaara/integrations/_mcp_upstream.py +++ b/src/vaara/integrations/_mcp_upstream.py @@ -16,11 +16,32 @@ import sys import threading from dataclasses import dataclass -from typing import Any, Callable, Optional +from typing import Any, Callable, Optional, Protocol, runtime_checkable logger = logging.getLogger(__name__) +@runtime_checkable +class UpstreamClient(Protocol): + """Transport-agnostic surface the proxy uses to talk to one upstream. + + The proxy never cares whether the upstream is a local stdio subprocess + (:class:`UpstreamMCPClient`) or a remote HTTP/SSE server + (``HttpUpstreamClient``); it only calls these three methods and is + handed an ``on_notification`` callback at construction time. Construction + differs per transport, so it is deliberately not part of the protocol. + """ + + def request(self, payload: dict, timeout: float = 30.0) -> dict: + """Send a JSON-RPC request, block for the response matching its id.""" + + def notify(self, payload: dict) -> None: + """Send a JSON-RPC notification (no response expected).""" + + def close(self) -> None: + """Release the transport (kill the subprocess / close the session).""" + + class ProxyError(Exception): """The proxy itself cannot serve a request. diff --git a/src/vaara/integrations/_mcp_upstream_http.py b/src/vaara/integrations/_mcp_upstream_http.py new file mode 100644 index 0000000..308f129 --- /dev/null +++ b/src/vaara/integrations/_mcp_upstream_http.py @@ -0,0 +1,365 @@ +"""Remote HTTP/SSE upstream MCP client for the proxy. + +Speaks the MCP Streamable HTTP transport (spec revisions 2025-03-26 and +2025-06-18) so the proxy can sit in front of MCP servers that are not local +stdio subprocesses. Built on the standard library only (``urllib``) to keep +vaara's zero-dependency core intact. + +What this implements: + * POST a JSON-RPC request to the single MCP endpoint and read the reply, + whether the server answers with ``application/json`` (one object) or a + ``text/event-stream`` (SSE) it closes once the reply has been sent. + * Capture the ``Mcp-Session-Id`` the server assigns on ``initialize`` and + echo it on every later request; send ``MCP-Protocol-Version`` once the + negotiated revision is known. + * Open a standing GET ``text/event-stream`` channel after the handshake to + receive server-initiated notifications, reconnecting with ``Last-Event-ID``. + * Inject caller-supplied static headers (e.g. ``Authorization: Bearer ...``) + on every call so authenticated remote servers are reachable. + +Deliberately NOT implemented: the deprecated 2024-11-05 two-endpoint HTTP+SSE +transport, and interactive OAuth / dynamic client registration. Auth is +static-header passthrough only. + +Internal module. Public surface is :class:`vaara.integrations.mcp_proxy.VaaraMCPProxy`. +""" + +from __future__ import annotations + +import json +import logging +import threading +import urllib.error +import urllib.request +from typing import Any, Callable, Iterator, Optional + +from vaara.integrations._mcp_upstream import ProxyError, strict_json_dumps + +logger = logging.getLogger(__name__) + +# Bounded wait between reconnect attempts on the standing server-to-client SSE +# stream so a flapping upstream cannot spin the reconnect thread hot. +_SSE_RECONNECT_BACKOFF_SECONDS = 1.0 + +# Socket read timeout on the standing GET stream. Bounds how long a blocked +# read parks, so close() (which only flips the closed flag) is observed within +# this window. We deliberately do NOT close the in-flight response from another +# thread to unblock it: that path goes through the BufferedReader lock the +# blocked read already holds and would deadlock. Comfortably longer than a +# compliant MCP server's keepalive cadence so a live stream never trips it. +_SSE_READ_TIMEOUT_SECONDS = 30.0 + +# Cap on an error-response body quoted back in a ProxyError, so a chatty +# upstream cannot blow up the proxy's logs or the downstream error message. +_ERROR_BODY_SNIPPET = 200 + + +class _ServerPushUnsupported(Exception): + """The upstream offers no GET server-to-client SSE channel. + + Raised internally when the standing-stream GET is met with 404/405/501 so + the listener loop stops cleanly instead of reconnecting forever. + """ + + +class HttpUpstreamClient: + """Talk to a remote MCP server over the Streamable HTTP transport. + + Matches the :class:`~vaara.integrations._mcp_upstream.UpstreamClient` + surface (:meth:`request`, :meth:`notify`, :meth:`close`) so the proxy treats + a remote upstream exactly like a local stdio one. ``on_notification`` is + called with every server-initiated JSON-RPC notification, both those that + arrive inline on a POST response stream and those on the standing GET stream. + """ + + def __init__( + self, + url: str, + headers: Optional[dict[str, str]] = None, + on_notification: Optional[Callable[[dict], None]] = None, + ) -> None: + self._url = url + # Caller-supplied static headers (auth). Applied first so the transport + # control headers always win on the keys the protocol owns. + self._extra_headers = dict(headers or {}) + self._on_notification = on_notification + self._lock = threading.Lock() + self._closed = threading.Event() + self._session_id: Optional[str] = None + self._protocol_version: Optional[str] = None + # The standing GET-SSE listener starts lazily after the first request + # (the MCP handshake), the earliest point a session id can exist. + self._listener_started = False + self._listener_last_event_id: Optional[str] = None + + # -- public UpstreamClient surface ------------------------------------ + + def request(self, payload: dict, timeout: float = 30.0) -> dict: + if self._closed.is_set(): + raise ProxyError("Upstream MCP server is closed") + if "id" not in payload: + raise ValueError( + "request() requires a JSON-RPC id; use notify() for notifications", + ) + resp = self._post(payload, timeout=timeout) + try: + self._capture_session(resp) + if self._is_event_stream(resp): + response = self._reply_from_sse(resp, payload["id"]) + else: + response = self._reply_from_json(resp) + finally: + resp.close() + # The matched reply must echo our id; a reply for another id is a + # protocol violation, not a result to return. + if response.get("id") != payload["id"]: + raise ProxyError( + f"Upstream MCP server replied to id {response.get('id')!r}, " + f"expected {payload['id']!r}", + ) + self._capture_protocol_version(payload, response) + self._ensure_listener() + return response + + def notify(self, payload: dict) -> None: + if self._closed.is_set(): + return + try: + resp = self._post(payload, timeout=30.0) + except ProxyError as e: + # A notification has no reply to wait on; a delivery failure is + # logged not raised, matching the stdio client's fire-and-forget. + logger.warning("Upstream MCP server rejected notification: %s", e) + return + try: + self._capture_session(resp) + if self._is_event_stream(resp): + # Some servers answer a notification POST with a short SSE + # stream of server-initiated messages; drain and route them. + for message in self._iter_messages(resp): + self._dispatch_unsolicited(message) + finally: + resp.close() + + def close(self) -> None: + # Flip the flag only. The standing-stream listener is a daemon thread + # that observes it within _SSE_READ_TIMEOUT_SECONDS, or at once when the + # server closes the connection. We must NOT close the in-flight response + # from here: that acquires the BufferedReader lock the blocked read + # already holds and would deadlock. + self._closed.set() + + # -- HTTP plumbing ---------------------------------------------------- + + def _post(self, payload: dict, timeout: float) -> Any: + body = strict_json_dumps(payload).encode("utf-8") + headers = self._headers(accept="application/json, text/event-stream") + req = urllib.request.Request(self._url, data=body, headers=headers, method="POST") + try: + return urllib.request.urlopen(req, timeout=timeout) # noqa: S310 + except urllib.error.HTTPError as e: + raise ProxyError( + f"Upstream MCP server returned HTTP {e.code}: {self._error_snippet(e)}", + ) from e + except urllib.error.URLError as e: + raise ProxyError(f"Upstream MCP server unreachable: {e.reason}") from e + except (TimeoutError, OSError) as e: + raise ProxyError(f"Upstream MCP server request failed: {e}") from e + + def _headers(self, *, accept: str) -> dict[str, str]: + headers = dict(self._extra_headers) + headers["Accept"] = accept + headers["Content-Type"] = "application/json" + with self._lock: + session_id = self._session_id + protocol_version = self._protocol_version + if session_id is not None: + headers["Mcp-Session-Id"] = session_id + if protocol_version is not None: + headers["MCP-Protocol-Version"] = protocol_version + return headers + + def _capture_session(self, resp: Any) -> None: + session_id = resp.headers.get("Mcp-Session-Id") + if session_id: + with self._lock: + self._session_id = session_id + + def _capture_protocol_version(self, payload: dict, response: dict) -> None: + if payload.get("method") != "initialize": + return + result = response.get("result") + version = result.get("protocolVersion") if isinstance(result, dict) else None + if isinstance(version, str) and version: + with self._lock: + self._protocol_version = version + + @staticmethod + def _is_event_stream(resp: Any) -> bool: + return "text/event-stream" in (resp.headers.get("Content-Type") or "").lower() + + @staticmethod + def _error_snippet(e: urllib.error.HTTPError) -> str: + try: + return e.read().decode("utf-8", "replace")[:_ERROR_BODY_SNIPPET] + except Exception: + return "" + + # -- reply extraction ------------------------------------------------- + + def _reply_from_json(self, resp: Any) -> dict: + raw = resp.read() + if not raw: + raise ProxyError("Upstream MCP server returned an empty response body") + try: + message = json.loads(raw) + except json.JSONDecodeError as e: + raise ProxyError(f"Upstream MCP server returned non-JSON: {e}") from e + if not isinstance(message, dict): + raise ProxyError("Upstream MCP server returned non-object JSON-RPC") + return message + + def _reply_from_sse(self, resp: Any, want_id: Any) -> dict: + # Stream events until the one bearing our id arrives; route anything + # else (server notifications) onward. The server closes the stream + # after delivering the reply. + for message in self._iter_messages(resp): + if message.get("id") == want_id: + return message + self._dispatch_unsolicited(message) + raise ProxyError( + f"Upstream MCP server closed the SSE stream before replying to id {want_id!r}", + ) + + def _iter_messages(self, resp: Any) -> Iterator[dict]: + """Yield each JSON-RPC object carried by the SSE stream's data events.""" + for event in self._iter_sse(resp): + data = event.get("data", "") + if not data: + continue + try: + message = json.loads(data) + except json.JSONDecodeError: + logger.warning("Upstream emitted non-JSON SSE data: %r", data[:200]) + continue + if isinstance(message, dict): + yield message + else: + logger.warning("Upstream emitted non-object JSON-RPC over SSE") + + @staticmethod + def _iter_sse(resp: Any) -> Iterator[dict]: + """Parse a server-sent-events byte stream into ``{data, id}`` events. + + Implements the subset of the SSE grammar MCP uses: ``data:`` (joined by + newline when multi-line), ``id:`` (for ``Last-Event-ID`` resume), + comment lines (``:`` prefix, heartbeats) ignored, ``event:``/``retry:`` + ignored. An empty line dispatches the accumulated event. + """ + data_lines: list[str] = [] + event_id: Optional[str] = None + for raw_line in resp: + line = raw_line.decode("utf-8", "replace").rstrip("\r\n") + if line == "": + if data_lines: + yield {"data": "\n".join(data_lines), "id": event_id} + data_lines = [] + event_id = None + continue + if line.startswith(":"): + continue + field, _, value = line.partition(":") + if value.startswith(" "): + value = value[1:] + if field == "data": + data_lines.append(value) + elif field == "id": + event_id = value + if data_lines: + yield {"data": "\n".join(data_lines), "id": event_id} + + def _dispatch_unsolicited(self, message: dict) -> None: + # Server-initiated requests (id + method) cannot be answered by the + # proxy on either transport, so they are dropped with a warning the + # same way the stdio reader treats an unknown id. Only true + # notifications (no id) reach the proxy's handler. + if "id" in message: + logger.warning( + "Upstream sent an unsolicited message with id %r; dropping", + message.get("id"), + ) + return + if self._on_notification is None: + return + try: + self._on_notification(message) + except Exception: + logger.exception("Notification handler raised") + + # -- standing server-to-client SSE channel ---------------------------- + + def _ensure_listener(self) -> None: + with self._lock: + if self._listener_started or self._closed.is_set(): + return + self._listener_started = True + thread = threading.Thread( + target=self._listen_loop, daemon=True, name="upstream-http-sse", + ) + thread.start() + + def _listen_loop(self) -> None: + while not self._closed.is_set(): + try: + self._read_standing_stream() + except _ServerPushUnsupported: + logger.info( + "Upstream %s offers no server-to-client SSE channel", self._url, + ) + return + except Exception as e: # network drop, parse error, closed socket + if self._closed.is_set(): + return + logger.debug("Upstream SSE stream ended (%s); reconnecting", e) + # Interruptible backoff: close() sets the event and we return early. + if self._closed.wait(_SSE_RECONNECT_BACKOFF_SECONDS): + return + + def _read_standing_stream(self) -> None: + headers = self._headers(accept="text/event-stream") + headers.pop("Content-Type", None) # no body on the GET + if self._listener_last_event_id is not None: + headers["Last-Event-ID"] = self._listener_last_event_id + req = urllib.request.Request(self._url, headers=headers, method="GET") + try: + resp = urllib.request.urlopen(req, timeout=_SSE_READ_TIMEOUT_SECONDS) # noqa: S310 + except urllib.error.HTTPError as e: + if e.code in (404, 405, 501): + raise _ServerPushUnsupported from e + raise + # resp is read and closed only on this listener thread, so the close in + # the finally never races a read on another thread. + try: + for event in self._iter_sse(resp): + if self._closed.is_set(): + return + if event.get("id") is not None: + self._listener_last_event_id = event["id"] + data = event.get("data", "") + if not data: + continue + try: + message = json.loads(data) + except json.JSONDecodeError: + logger.warning("Upstream emitted non-JSON SSE data: %r", data[:200]) + continue + if isinstance(message, dict): + self._dispatch_unsolicited(message) + finally: + try: + resp.close() + except Exception: + # Best-effort close. The response may already be closed by the + # peer or torn down from another thread during shutdown; there + # is nothing to recover here. + pass diff --git a/src/vaara/integrations/mcp_proxy.py b/src/vaara/integrations/mcp_proxy.py index 47329a1..a5a957b 100644 --- a/src/vaara/integrations/mcp_proxy.py +++ b/src/vaara/integrations/mcp_proxy.py @@ -55,8 +55,9 @@ policy_hash_from_perimeter, ) from vaara.integrations._mcp_upstream import ( - ProxyError, UpstreamMCPClient, strict_json_dumps, + ProxyError, UpstreamClient, UpstreamMCPClient, strict_json_dumps, ) +from vaara.integrations._mcp_upstream_http import HttpUpstreamClient from vaara.pipeline import InterceptionPipeline from vaara.taxonomy.actions import ActionRequest @@ -114,6 +115,61 @@ def _safe_log(value: Any, max_len: int = 200) -> str: # a CLI flag if a real workload needs more headroom. _MCP_HTTP_MAX_BODY_BYTES = 1 * 1024 * 1024 +# Maximum Mcp-Session-Id length accepted on the /mcp HTTP endpoint. The id +# keys the inflight-progress and HttpRouter session maps, so the cap bounds +# those keys against a malicious client submitting an absurdly long header. +# 128 is comfortably wider than any realistic cryptographically-random id. +_MCP_SESSION_ID_MAX_LEN = 128 + +# Streamable HTTP transport revisions the /mcp endpoint speaks. The +# MCP-Protocol-Version header arrived in 2025-06-18; per spec a request that +# omits it is assumed to be 2025-03-26, and an unrecognised value is a 400. +_SUPPORTED_HTTP_PROTOCOL_VERSIONS = frozenset({"2025-03-26", "2025-06-18"}) + + +def _session_id_is_visible_ascii(value: str) -> bool: + """True iff every character is visible ASCII (0x21-0x7E). + + The Streamable HTTP transport requires session ids to be visible + ASCII. The empty string passes vacuously; callers cap length and + presence separately. + """ + return all("\x21" <= ch <= "\x7e" for ch in value) + + +def _protocol_version_supported(version: Optional[str]) -> bool: + """True iff the MCP-Protocol-Version header is absent, blank, or known. + + An absent or blank header is allowed: per spec the server assumes + 2025-03-26. A present, non-blank value must be one the transport speaks. + """ + if version is None: + return True + stripped = version.strip() + return stripped == "" or stripped in _SUPPORTED_HTTP_PROTOCOL_VERSIONS + + +def _accept_satisfies(accept: Optional[str], media_type: str) -> bool: + """True iff an Accept header value can receive ``media_type``. + + A missing or blank header states no preference and is accepted. A + present header satisfies ``media_type`` when it lists ``*/*``, the + matching type wildcard (e.g. ``application/*``), or the exact type. + Wildcard-aware where a literal substring check would not be. + """ + if not accept or not accept.strip(): + return True + main_type = media_type.split("/", 1)[0] + tokens = { + token.strip().split(";", 1)[0].strip().lower() + for token in accept.split(",") + } + return ( + "*/*" in tokens + or f"{main_type}/*" in tokens + or media_type.lower() in tokens + ) + class VaaraMCPProxy: """Transparent MCP proxy with Vaara interception on tool calls.""" @@ -135,6 +191,8 @@ def __init__( overt_emitter: Optional[OVERTReceiptEmitter] = None, attest_emitter: Optional[AttestPairEmitter] = None, upstreams: Optional[dict[str, list[str]]] = None, + upstream_urls: Optional[dict[str, str]] = None, + upstream_headers: Optional[dict[str, dict[str, str]]] = None, router: Optional[NotificationRouter] = None, ) -> None: if pipeline is not None: @@ -206,30 +264,58 @@ def __init__( upstream_map = {name: list(cmd) for name, cmd in upstreams.items()} elif upstream_command is not None: upstream_map = {"default": list(upstream_command)} - else: + # v0.45: remote upstreams reached over the MCP Streamable HTTP transport. + # A slot is either a stdio command or a URL, never both. Optional static + # headers (auth) attach per URL slot. + url_map: dict[str, str] = dict(upstream_urls) if upstream_urls else {} + header_map: dict[str, dict[str, str]] = ( + {n: dict(h) for n, h in upstream_headers.items()} if upstream_headers else {} + ) + collisions = set(upstream_map) & set(url_map) + if collisions: raise ValueError( - "VaaraMCPProxy requires upstream_command or upstreams.", + f"Upstream slot(s) {sorted(collisions)!r} given as both a stdio " + "command and a URL; each slot is exactly one transport.", ) + stray_headers = set(header_map) - set(url_map) + if stray_headers: + raise ValueError( + f"Upstream header(s) for {sorted(stray_headers)!r} match no " + "--upstream-url slot; headers apply to URL upstreams only.", + ) + if not upstream_map and not url_map: + raise ValueError( + "VaaraMCPProxy requires at least one upstream " + "(upstream_command, upstreams, or upstream_urls).", + ) + all_names = set(upstream_map) | set(url_map) default_alias_target: Optional[str] = None - if "default" not in upstream_map: + if "default" not in all_names: # Pick a stable fallback so requests without X-Vaara-Upstream # still resolve. Lexicographic first keeps multi-tenant fleets # deterministic across restarts. Alias the slot rather than - # cloning the command so we never spawn a duplicate subprocess. - default_alias_target = sorted(upstream_map)[0] - # Wrap on_notification per upstream so the reader thread's callback + # cloning the transport so we never open a duplicate connection. + default_alias_target = sorted(all_names)[0] + # Wrap on_notification per upstream so the reader/listener callback # carries the upstream's name. Default-arg ``n=name`` binds the loop - # variable at lambda creation, avoiding the late-binding bug that - # would otherwise pin every upstream to the last name in the dict. - self._upstreams: dict[str, UpstreamMCPClient] = { - name: UpstreamMCPClient( + # variable at definition, avoiding the late-binding bug that would + # otherwise pin every upstream to the last name iterated. + self._upstreams: dict[str, UpstreamClient] = {} + for name, command in upstream_map.items(): + self._upstreams[name] = UpstreamMCPClient( command=command, on_notification=( lambda msg, n=name: self._on_upstream_notification(n, msg) ), ) - for name, command in upstream_map.items() - } + for name, url in url_map.items(): + self._upstreams[name] = HttpUpstreamClient( + url=url, + headers=header_map.get(name), + on_notification=( + lambda msg, n=name: self._on_upstream_notification(n, msg) + ), + ) if default_alias_target is not None: self._upstreams["default"] = self._upstreams[default_alias_target] # ``self._upstream`` resolves to the per-request upstream via the @@ -239,7 +325,7 @@ def __init__( # request to dispatch into the right fleet member. @property - def _upstream(self) -> UpstreamMCPClient: + def _upstream(self) -> UpstreamClient: """Resolve the upstream MCP client for the current request scope. HTTP transport sets ``_REQUEST_UPSTREAM`` per inbound request. stdio @@ -258,7 +344,7 @@ def _upstream(self) -> UpstreamMCPClient: return client @_upstream.setter - def _upstream(self, client: UpstreamMCPClient) -> None: + def _upstream(self, client: UpstreamClient) -> None: """Replace the default-slot upstream client. Test fixtures and embedders that previously assigned @@ -367,6 +453,39 @@ async def mcp_endpoint( mcp_session_id: Optional[str] = Header(default=None, alias="Mcp-Session-Id"), x_vaara_intent: Optional[str] = Header(default=None, alias="X-Vaara-Intent"), ) -> Response: + # Streamable HTTP transport header validation (MCP 2025-03-26 / + # 2025-06-18): the client must be able to accept both + # application/json and text/event-stream, and any + # MCP-Protocol-Version it sends must be one we speak. Reject + # transport violations before reading or parsing the body. + accept = request.headers.get("accept") + if not ( + _accept_satisfies(accept, "application/json") + and _accept_satisfies(accept, "text/event-stream") + ): + raise HTTPException( + status_code=406, + detail={"error": { + "code": "not_acceptable", + "message": ( + "Accept header must allow both application/json " + "and text/event-stream" + ), + }}, + ) + if not _protocol_version_supported( + request.headers.get("mcp-protocol-version") + ): + raise HTTPException( + status_code=400, + detail={"error": { + "code": "unsupported_protocol_version", + "message": ( + "MCP-Protocol-Version is not supported; supported: " + f"{sorted(_SUPPORTED_HTTP_PROTOCOL_VERSIONS)!r}" + ), + }}, + ) # 1 MiB cap on a single MCP JSON-RPC message. Real tool calls and # responses fit comfortably; anything larger is either a misuse or # a DoS attempt against the proxy's JSON parser. The cap is the @@ -453,16 +572,26 @@ async def mcp_endpoint( ) session_value = (mcp_session_id or "").strip() - # Cap session id length to keep the inflight-progress and HttpRouter - # session-map keys bounded against a malicious client that submits - # an absurdly long header. 128 chars is comfortably wider than any - # realistic cryptographically-random session id. - if len(session_value) > 128: + if len(session_value) > _MCP_SESSION_ID_MAX_LEN: raise HTTPException( status_code=400, detail={"error": { "code": "session_id_too_long", - "message": "Mcp-Session-Id must be 128 characters or fewer", + "message": ( + f"Mcp-Session-Id must be {_MCP_SESSION_ID_MAX_LEN} " + "characters or fewer" + ), + }}, + ) + if not _session_id_is_visible_ascii(session_value): + raise HTTPException( + status_code=400, + detail={"error": { + "code": "session_id_invalid", + "message": ( + "Mcp-Session-Id must contain only visible ASCII " + "characters (0x21-0x7E)" + ), }}, ) upstream_token = _REQUEST_UPSTREAM.set(upstream_name) @@ -492,6 +621,19 @@ async def mcp_sse_endpoint( mcp_session_id: Optional[str] = Header(default=None, alias="Mcp-Session-Id"), last_event_id: Optional[str] = Header(default=None, alias="Last-Event-ID"), ) -> StreamingResponse: + if not _protocol_version_supported( + request.headers.get("mcp-protocol-version") + ): + raise HTTPException( + status_code=400, + detail={"error": { + "code": "unsupported_protocol_version", + "message": ( + "MCP-Protocol-Version is not supported; supported: " + f"{sorted(_SUPPORTED_HTTP_PROTOCOL_VERSIONS)!r}" + ), + }}, + ) session_value = (mcp_session_id or "").strip() if not session_value: raise HTTPException( @@ -501,12 +643,26 @@ async def mcp_sse_endpoint( "message": "Mcp-Session-Id header is required for GET /mcp", }}, ) - if len(session_value) > 128: + if len(session_value) > _MCP_SESSION_ID_MAX_LEN: raise HTTPException( status_code=400, detail={"error": { "code": "session_id_too_long", - "message": "Mcp-Session-Id must be 128 characters or fewer", + "message": ( + f"Mcp-Session-Id must be {_MCP_SESSION_ID_MAX_LEN} " + "characters or fewer" + ), + }}, + ) + if not _session_id_is_visible_ascii(session_value): + raise HTTPException( + status_code=400, + detail={"error": { + "code": "session_id_invalid", + "message": ( + "Mcp-Session-Id must contain only visible ASCII " + "characters (0x21-0x7E)" + ), }}, ) header_name = (x_vaara_upstream or "").strip() @@ -1298,6 +1454,23 @@ def main(argv: Optional[list[str]] = None) -> None: ) parser.add_argument("--upstream-arg", action="append", default=[], dest="upstream_args", help="Argument to pass to the (first) upstream command (repeatable)") + parser.add_argument( + "--upstream-url", action="append", default=[], dest="upstream_urls", + help=( + "Remote upstream MCP server reached over the Streamable HTTP " + "transport. Repeatable: `--upstream-url NAME=URL` registers a named " + "slot; bare `--upstream-url URL` lands under 'default'. A slot is " + "either --upstream (stdio) or --upstream-url (remote), never both." + ), + ) + parser.add_argument( + "--upstream-header", action="append", default=[], dest="upstream_headers", + help=( + "Static header sent on every request to a --upstream-url slot, e.g. " + "`--upstream-header NAME=Authorization: Bearer TOKEN`. Repeatable for " + "multiple headers or slots. The slot NAME must match an --upstream-url." + ), + ) parser.add_argument( "--transport", choices=["stdio", "http"], @@ -1387,32 +1560,43 @@ def main(argv: Optional[list[str]] = None) -> None: ) upstreams = _parse_upstream_specs(args.upstreams, args.upstream_args) - if not upstreams: + upstream_urls = _parse_upstream_url_specs(args.upstream_urls) + upstream_headers = _parse_upstream_header_specs(args.upstream_headers) + if not upstreams and not upstream_urls: parser.error( - "at least one --upstream is required (e.g. `--upstream " - "github=github-mcp-server` or `--upstream npx`).", + "at least one --upstream or --upstream-url is required (e.g. " + "`--upstream github=github-mcp-server` or " + "`--upstream-url remote=https://host/mcp`).", ) attest_emitter = _build_attest_emitter_from_args( args, upstreams=_attest_upstreams_for_slots(upstreams), ) + # The legacy single-upstream entry point only applies to a lone stdio + # upstream with no remote slots in play. legacy_single = ( - list(next(iter(upstreams.values()))) if len(upstreams) == 1 else None - ) - proxy = VaaraMCPProxy( - upstream_command=legacy_single, - upstreams=upstreams if legacy_single is None else None, - db_path=args.db, agent_id_default=args.agent_id, - allowlist=tool_allow, - denylist=tool_deny if tool_deny else None, - resource_allowlist=resource_allow, - resource_denylist=resource_deny if resource_deny else None, - prompt_allowlist=prompt_allow, - prompt_denylist=prompt_deny if prompt_deny else None, - overt_emitter=overt_emitter, - attest_emitter=attest_emitter, + list(next(iter(upstreams.values()))) + if (len(upstreams) == 1 and not upstream_urls) else None ) + try: + proxy = VaaraMCPProxy( + upstream_command=legacy_single, + upstreams=upstreams if (legacy_single is None and upstreams) else None, + upstream_urls=upstream_urls or None, + upstream_headers=upstream_headers or None, + db_path=args.db, agent_id_default=args.agent_id, + allowlist=tool_allow, + denylist=tool_deny if tool_deny else None, + resource_allowlist=resource_allow, + resource_denylist=resource_deny if resource_deny else None, + prompt_allowlist=prompt_allow, + prompt_denylist=prompt_deny if prompt_deny else None, + overt_emitter=overt_emitter, + attest_emitter=attest_emitter, + ) + except ValueError as e: + parser.error(str(e)) try: if args.transport == "http": proxy.run_http( @@ -1469,6 +1653,67 @@ def _parse_upstream_specs( return upstreams +def _parse_upstream_url_specs(url_specs: list[str]) -> dict[str, str]: + """Turn ``--upstream-url`` CLI input into a name -> URL map. + + Each value is ``NAME=URL`` (NAME a short slug) or a bare ``URL`` (lands + under "default"). The URL must be http(s); the scheme check also keeps a + bare URL containing ``=`` in its query string from being misread as + ``NAME=URL``. + """ + urls: dict[str, str] = {} + for spec in url_specs: + spec = spec.strip() + if spec.lower().startswith(("http://", "https://")): + name, url = "default", spec + else: + candidate_name, sep, candidate_url = spec.partition("=") + candidate_name = candidate_name.strip() + candidate_url = candidate_url.strip() + if ( + sep + and _UPSTREAM_NAME_RE.match(candidate_name) + and candidate_url.lower().startswith(("http://", "https://")) + ): + name, url = candidate_name, candidate_url + else: + raise SystemExit( + f"invalid --upstream-url value {spec!r}; expected " + "NAME=URL or URL (http/https)", + ) + if name in urls: + raise SystemExit(f"duplicate --upstream-url slot {name!r}") + urls[name] = url + return urls + + +def _parse_upstream_header_specs(header_specs: list[str]) -> dict[str, dict[str, str]]: + """Turn ``--upstream-header`` CLI input into a name -> {header: value} map. + + Each value is ``NAME=Header-Name: header value``. Splitting NAME off the + first ``=`` is unambiguous because the slug pattern never matches a header + line, so a base64 token carrying ``=`` in the value stays intact. + """ + headers: dict[str, dict[str, str]] = {} + for spec in header_specs: + name, sep, header_line = spec.partition("=") + name = name.strip() + if not sep or not _UPSTREAM_NAME_RE.match(name): + raise SystemExit( + f"invalid --upstream-header value {spec!r}; expected " + "NAME=Header-Name: value", + ) + field, colon, value = header_line.partition(":") + field = field.strip() + if not colon or not field: + raise SystemExit( + f"invalid --upstream-header value {spec!r}; the header must be " + "'Header-Name: value'", + ) + headers.setdefault(name, {})[field] = value.strip() + return headers + + def _attest_upstreams_for_slots( upstreams: dict[str, list[str]], ) -> dict[str, list[str]]: diff --git a/tests/test_attestation_vectors.py b/tests/test_attestation_vectors.py new file mode 100644 index 0000000..87c5d85 --- /dev/null +++ b/tests/test_attestation_vectors.py @@ -0,0 +1,103 @@ +"""Guard: the committed v0 SEP-2787 attestation vectors are honest. + +Two layers. First, the stdlib-only ``_check_independent.py`` walker +(cryptography + rfc8785, no Vaara import) must verify every committed +fixture: if the format or a fixture drifts, the second-implementation +checker fails here in CI rather than silently shipping broken vectors. +Second, the same fixtures are re-verified through the Vaara library +(``verify_attestation`` + ``verify_args_commitment``) so the published +``expected.json`` verdicts match the reference implementation, not just +the independent walker. +""" + +from __future__ import annotations + +import importlib.util +import json +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +for _mod in ("rfc8785", "cryptography"): + if importlib.util.find_spec(_mod) is None: + pytest.skip( + "attestation extra not installed (pip install 'vaara[attestation]')", + allow_module_level=True, + ) + +from cryptography.hazmat.primitives import serialization # noqa: E402 + +from vaara.attestation.sep2787 import ( # noqa: E402 + parse_attestation, + verify_args_commitment, + verify_attestation, +) + +VECTORS = Path(__file__).parent / "vectors" / "sep2787_attestation_v0" +KEYS = VECTORS / "keys" +CHECKER = VECTORS / "_check_independent.py" + +# Matches EVAL_NOW in _check_independent.py and the generator. +EVAL_NOW = datetime(2026, 5, 29, 10, 0, 30, tzinfo=timezone.utc).timestamp() + + +def _load_checker(): + spec = importlib.util.spec_from_file_location( + "_attestation_vector_checker", CHECKER) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _verifying_material(alg: str): + if alg == "HS256": + return (KEYS / "hs256_secret.bin").read_bytes() + if alg == "ES256": + return serialization.load_pem_public_key( + (KEYS / "es256_public.pem").read_bytes()) + if alg == "RS256": + return serialization.load_pem_public_key( + (KEYS / "rs256_public.pem").read_bytes()) + raise AssertionError(f"unexpected alg {alg!r}") + + +def _cases(): + return sorted(p for p in (VECTORS / "normative").iterdir() if p.is_dir()) + + +def test_independent_walker_passes_all_cases(): + assert _load_checker().main() == 0 + + +def test_at_least_six_cases_present(): + assert len(_cases()) >= 6 + + +@pytest.mark.parametrize("case", _cases(), ids=lambda p: p.name) +def test_library_verdicts_match_expected(case): + raw = json.loads((case / "attestation.json").read_text()) + expected = json.loads((case / "expected.json").read_text()) + att = parse_attestation(raw) + material = _verifying_material(att.alg) + + # now=0 makes the TTL deadline trivially pass, isolating the signature. + sig_ok = verify_attestation(att, verifying_material=material, now=0.0) + assert sig_ok == expected["signature_ok"] + + # At EVAL_NOW the verdict is signature AND TTL. + combined = verify_attestation(att, verifying_material=material, now=EVAL_NOW) + assert combined == (expected["signature_ok"] and expected["ttl_ok"]) + + runtime_args_path = case / "runtime_args.json" + if runtime_args_path.exists(): + runtime_args = json.loads(runtime_args_path.read_text()) + result = verify_args_commitment( + att.payload_derived.tool_calls[0].args, + runtime_arguments=runtime_args, + ) + assert result.ok == expected["args_commitment_ok"] + assert result.projection_match == expected["projection_match"] + else: + assert expected["args_commitment_ok"] is None + assert expected["projection_match"] is None diff --git a/tests/test_mcp_proxy_conformance.py b/tests/test_mcp_proxy_conformance.py new file mode 100644 index 0000000..2b0d9f9 --- /dev/null +++ b/tests/test_mcp_proxy_conformance.py @@ -0,0 +1,208 @@ +"""v0.45 Streamable HTTP transport conformance. + +Three checks the proxy was missing: Mcp-Session-Id visible-ASCII charset +(alongside the existing length cap), MCP-Protocol-Version validation on +POST and GET, and POST Accept negotiation (the client must be able to +receive both application/json and text/event-stream). +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from vaara.integrations import mcp_proxy +from vaara.integrations.mcp_proxy import ( + VaaraMCPProxy, + _accept_satisfies, + _protocol_version_supported, + _session_id_is_visible_ascii, +) + +try: + from fastapi.testclient import TestClient + + HAS_FASTAPI = True +except ImportError: + HAS_FASTAPI = False + +needs_server = pytest.mark.skipif( + not HAS_FASTAPI, + reason="server extra not installed (pip install 'vaara[server]')", +) + +_NOTIFY = {"jsonrpc": "2.0", "method": "notifications/initialized"} +_PAYLOAD = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"} +_BOTH = "application/json, text/event-stream" + + +# ── pure predicate units (no server extra required) ──────────────────────── + +@pytest.mark.parametrize("value,ok", [ + ("", True), + ("abc-123_XYZ.~", True), + ("!~", True), # boundary code points 0x21 and 0x7E + ("has space", False), # 0x20 is below the visible range + ("tab\there", False), # 0x09 + ("del\x7f", False), # 0x7F + ("unié", False), # non-ASCII +]) +def test_session_id_visible_ascii(value, ok): + assert _session_id_is_visible_ascii(value) is ok + + +@pytest.mark.parametrize("version,ok", [ + (None, True), + ("", True), + (" ", True), + ("2025-03-26", True), + ("2025-06-18", True), + ("2024-11-05", False), # old two-endpoint transport, not this one + ("garbage", False), +]) +def test_protocol_version_supported(version, ok): + assert _protocol_version_supported(version) is ok + + +@pytest.mark.parametrize("accept,media_type,ok", [ + (None, "application/json", True), + ("", "application/json", True), + ("*/*", "text/event-stream", True), + ("application/*", "application/json", True), + ("application/json", "application/json", True), + ("application/json", "text/event-stream", False), + (_BOTH, "text/event-stream", True), + ("application/json;q=0.9, text/event-stream;q=0.1", "text/event-stream", True), + ("text/html", "application/json", False), +]) +def test_accept_satisfies(accept, media_type, ok): + assert _accept_satisfies(accept, media_type) is ok + + +# ── endpoint behaviour ───────────────────────────────────────────────────── + +@pytest.fixture +def http_proxy(monkeypatch): + monkeypatch.setattr(mcp_proxy, "UpstreamMCPClient", MagicMock()) + return VaaraMCPProxy( + upstreams={"alpha": ["cmd-alpha"], "beta": ["cmd-beta"]}, + pipeline=MagicMock(), + ) + + +def _client(proxy): + import unittest.mock as um + + with um.patch("uvicorn.run") as run_mock: + captured: dict = {} + run_mock.side_effect = lambda app, **kw: captured.__setitem__("app", app) + proxy.run_http(host="127.0.0.1", port=0) + return TestClient(captured["app"]) + + +@needs_server +def test_post_accept_json_only_rejected(http_proxy): + resp = _client(http_proxy).post( + "/mcp", json=_NOTIFY, + headers={"Accept": "application/json", "X-Vaara-Upstream": "alpha"}, + ) + assert resp.status_code == 406 + assert resp.json()["detail"]["error"]["code"] == "not_acceptable" + + +@needs_server +def test_post_accept_both_ok(http_proxy): + resp = _client(http_proxy).post( + "/mcp", json=_NOTIFY, + headers={"Accept": _BOTH, "X-Vaara-Upstream": "alpha"}, + ) + assert resp.status_code == 202 + + +@needs_server +def test_post_default_wildcard_accept_still_ok(http_proxy): + # TestClient sends Accept: */* by default; the wildcard must satisfy. + resp = _client(http_proxy).post( + "/mcp", json=_NOTIFY, headers={"X-Vaara-Upstream": "alpha"}, + ) + assert resp.status_code == 202 + + +@needs_server +def test_post_unsupported_protocol_version_rejected(http_proxy): + resp = _client(http_proxy).post( + "/mcp", json=_NOTIFY, + headers={ + "Accept": _BOTH, + "X-Vaara-Upstream": "alpha", + "MCP-Protocol-Version": "1999-01-01", + }, + ) + assert resp.status_code == 400 + assert resp.json()["detail"]["error"]["code"] == "unsupported_protocol_version" + + +@needs_server +def test_post_supported_protocol_version_ok(http_proxy): + resp = _client(http_proxy).post( + "/mcp", json=_NOTIFY, + headers={ + "Accept": _BOTH, + "X-Vaara-Upstream": "alpha", + "MCP-Protocol-Version": "2025-06-18", + }, + ) + assert resp.status_code == 202 + + +@needs_server +def test_post_session_id_non_visible_ascii_rejected(http_proxy): + # An embedded space (0x20) survives .strip() and is below the visible range. + resp = _client(http_proxy).post( + "/mcp", json=_PAYLOAD, + headers={ + "Accept": _BOTH, + "X-Vaara-Upstream": "alpha", + "Mcp-Session-Id": "bad id", + }, + ) + assert resp.status_code == 400 + assert resp.json()["detail"]["error"]["code"] == "session_id_invalid" + + +@needs_server +def test_post_session_id_too_long_rejected(http_proxy): + resp = _client(http_proxy).post( + "/mcp", json=_PAYLOAD, + headers={ + "Accept": _BOTH, + "X-Vaara-Upstream": "alpha", + "Mcp-Session-Id": "a" * 129, + }, + ) + assert resp.status_code == 400 + assert resp.json()["detail"]["error"]["code"] == "session_id_too_long" + + +@needs_server +def test_get_unsupported_protocol_version_rejected(http_proxy): + resp = _client(http_proxy).get( + "/mcp", + headers={ + "Mcp-Session-Id": "sess-1", + "MCP-Protocol-Version": "1999-01-01", + }, + ) + assert resp.status_code == 400 + assert resp.json()["detail"]["error"]["code"] == "unsupported_protocol_version" + + +@needs_server +def test_get_session_id_non_visible_ascii_rejected(http_proxy): + resp = _client(http_proxy).get( + "/mcp", + headers={"Mcp-Session-Id": "bad id", "X-Vaara-Upstream": "alpha"}, + ) + assert resp.status_code == 400 + assert resp.json()["detail"]["error"]["code"] == "session_id_invalid" diff --git a/tests/test_mcp_upstream_http.py b/tests/test_mcp_upstream_http.py new file mode 100644 index 0000000..d5425b4 --- /dev/null +++ b/tests/test_mcp_upstream_http.py @@ -0,0 +1,217 @@ +"""HttpUpstreamClient against a real stdlib HTTP/SSE MCP server. + +Exercises both reply transports (application/json and text/event-stream), +session-id + auth-header echo, the standing server-to-client SSE channel, and +the error path. The fake server runs on an ephemeral loopback port. +""" + +from __future__ import annotations + +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from vaara.integrations._mcp_upstream import ProxyError +from vaara.integrations._mcp_upstream_http import HttpUpstreamClient + + +class _Handler(BaseHTTPRequestHandler): + def log_message(self, *args): # silence the test server + pass + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(length)) if length else None + + def _send_json(self, obj, *, status=200, session=None): + body = json.dumps(obj).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + if session is not None: + self.send_header("Mcp-Session-Id", session) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + cfg = self.server.cfg + msg = self._read_body() + cfg["last_post_headers"] = dict(self.headers) + if isinstance(msg, dict) and "id" not in msg: # notification + cfg["notifications"].append(msg) + self.send_response(202) + self.end_headers() + return + if msg.get("method") == "initialize": + self._send_json( + {"jsonrpc": "2.0", "id": msg["id"], + "result": {"protocolVersion": "2025-06-18", "serverInfo": {"name": "fake"}}}, + session="sess-123", + ) + return + if cfg.get("http_error"): + self._send_json({"error": "boom"}, status=cfg["http_error"]) + return + if cfg.get("reply_mode") == "sse": + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + note = {"jsonrpc": "2.0", "method": "notifications/progress", "params": {}} + reply = {"jsonrpc": "2.0", "id": msg["id"], "result": {"ok": True}} + self.wfile.write(b"data: " + json.dumps(note).encode() + b"\n\n") + self.wfile.write(b"data: " + json.dumps(reply).encode() + b"\n\n") + self.wfile.flush() + return + self._send_json({"jsonrpc": "2.0", "id": msg["id"], "result": {"ok": True}}) + + def do_GET(self): + cfg = self.server.cfg + if cfg.get("no_push"): + self.send_response(405) + self.end_headers() + return + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + note = {"jsonrpc": "2.0", "method": "notifications/message", "params": {"hi": 1}} + try: + self.wfile.write(b"id: 1\ndata: " + json.dumps(note).encode() + b"\n\n") + self.wfile.flush() + except OSError: + return + while not cfg["stop"].is_set(): # hold the standing stream open + time.sleep(0.02) + + +@pytest.fixture +def server(): + httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + httpd.daemon_threads = True + httpd.cfg = {"notifications": [], "stop": threading.Event()} + threading.Thread(target=httpd.serve_forever, daemon=True).start() + url = f"http://127.0.0.1:{httpd.server_address[1]}/mcp" + try: + yield httpd, url + finally: + httpd.cfg["stop"].set() + httpd.shutdown() + + +def _init(client, rid=1): + return client.request({"jsonrpc": "2.0", "id": rid, "method": "initialize", "params": {}}) + + +def test_initialize_captures_session_and_version(server): + httpd, url = server + client = HttpUpstreamClient(url) + try: + reply = _init(client) + assert reply["result"]["protocolVersion"] == "2025-06-18" + assert client._session_id == "sess-123" + assert client._protocol_version == "2025-06-18" + finally: + client.close() + + +def test_session_and_auth_echoed_on_later_request(server): + httpd, url = server + client = HttpUpstreamClient(url, headers={"Authorization": "Bearer tok-xyz"}) + try: + _init(client) + reply = client.request({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + assert reply["result"] == {"ok": True} + sent = httpd.cfg["last_post_headers"] + assert sent.get("Mcp-Session-Id") == "sess-123" + assert sent.get("Mcp-Protocol-Version") == "2025-06-18" + assert sent.get("Authorization") == "Bearer tok-xyz" + finally: + client.close() + + +def test_sse_reply_routes_notification_and_returns(server): + httpd, url = server + httpd.cfg["reply_mode"] = "sse" + httpd.cfg["no_push"] = True # isolate the inline-SSE path from the standing one + seen = [] + client = HttpUpstreamClient(url, on_notification=seen.append) + try: + _init(client) + reply = client.request({"jsonrpc": "2.0", "id": 7, "method": "tools/list"}) + assert reply == {"jsonrpc": "2.0", "id": 7, "result": {"ok": True}} + assert any(m.get("method") == "notifications/progress" for m in seen) + finally: + client.close() + + +def test_standing_stream_delivers_server_notification(server): + httpd, url = server + got = threading.Event() + received = [] + + def on_note(msg): + received.append(msg) + got.set() + + client = HttpUpstreamClient(url, on_notification=on_note) + try: + _init(client) # starts the standing GET listener + assert got.wait(timeout=5.0), "no server-initiated notification arrived" + assert received[0]["method"] == "notifications/message" + finally: + client.close() + + +def test_http_error_raises_proxyerror(server): + httpd, url = server + httpd.cfg["http_error"] = 500 + httpd.cfg["no_push"] = True + client = HttpUpstreamClient(url) + try: + _init(client) + with pytest.raises(ProxyError, match="HTTP 500"): + client.request({"jsonrpc": "2.0", "id": 3, "method": "tools/list"}) + finally: + client.close() + + +def test_request_after_close_raises(server): + httpd, url = server + client = HttpUpstreamClient(url) + client.close() + with pytest.raises(ProxyError, match="closed"): + _init(client) + + +def test_proxy_fronts_remote_http_upstream(server): + """End-to-end: VaaraMCPProxy routes a tools/call to a remote HTTP upstream.""" + from dataclasses import dataclass + from unittest.mock import MagicMock + + from vaara.integrations.mcp_proxy import VaaraMCPProxy + + @dataclass + class _Allow: + allowed: bool = True + action_id: str = "act-1" + reason: str = "" + decision: str = "ALLOW" + + httpd, url = server + httpd.cfg["no_push"] = True # keep this test to the request path + pipeline = MagicMock() + pipeline.intercept.return_value = _Allow() + proxy = VaaraMCPProxy(upstream_urls={"default": url}, pipeline=pipeline) + try: + req = { + "jsonrpc": "2.0", "id": 21, "method": "tools/call", + "params": {"name": "remote.tool", "arguments": {}}, + } + resp = proxy._handle_tools_call(req) + assert resp["id"] == 21 + assert resp["result"] == {"ok": True} + pipeline.report_outcome.assert_called_once() + finally: + proxy.close() diff --git a/tests/vectors/sep2787_attestation_v0/README.md b/tests/vectors/sep2787_attestation_v0/README.md new file mode 100644 index 0000000..d41b1d5 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/README.md @@ -0,0 +1,87 @@ +# SEP-2787 attestation conformance vectors, v0 + +Fixtures for the SEP-2787 tool call attestation envelope (v2 shape). +Each case pins a signed attestation and the verdict a conformant +verifier must reach for it. The companion post-execution receipt +vectors live in `tests/vectors/execution_receipt_v0/`. + +## Layout + +``` +keys/ pinned signing material + hs256_secret.bin 32 raw bytes + es256_private.pem PKCS8, raw r||s signatures (not DER) + es256_public.pem SubjectPublicKeyInfo + rs256_private.pem PKCS8 + rs256_public.pem SubjectPublicKeyInfo +normative// + attestation.json the signed SEP-2787 envelope + runtime_args.json the tools/call arguments (commitment cases) + expected.json {signature_ok, ttl_ok, + args_commitment_ok, projection_match} +_check_independent.py stdlib + cryptography + rfc8785, no Vaara import +``` + +## Verdict dimensions + +`verify_attestation` covers two of the SEP-2787 verification steps: +signature (step 1) and TTL (step 3). The argument commitment (step 5) +is exposed separately as `verify_args_commitment` and is composed once +the runtime `tools/call` arguments are in hand. The fixtures pin all +three: + +- `signature_ok`: signature over the JCS-canonical encoding of + `{version, alg, plannerDeclared, issuerAsserted, payloadDerived}`. +- `ttl_ok`: `iat + expSeconds + 30s skew >= now`, evaluated at the + pinned instant `2026-05-29T10:00:30Z`. +- `args_commitment_ok`: the commitment binds the supplied runtime + arguments. `null` when no `runtime_args.json` is present (the + attestation was verified for signature and TTL only). +- `projection_match`: for `ArgsProjection`, `true` when the projection + is an identity projection of the runtime arguments (the canonical + arguments themselves or a hash-only-identity `{"digest": "..."}` + whose embedded digest matches), `false` for a redacted projection + (signed only, no completeness claim), `null` otherwise. + +## Cases + +- `hs256_digest_identity`: HS256, hash-only-identity commitment + (`make_args_digest`), all three checks pass. +- `es256_projection_identity`: ES256, identity projection of the full + arguments, all three checks pass. +- `rs256_signature_ttl_only`: RS256, signature and TTL valid, no runtime + arguments supplied, so `args_commitment_ok` is `null`. +- `neg_bad_signature`: signature byte flipped; TTL and the commitment + still hold, only the signature fails. +- `neg_expired`: signature valid but the envelope is past its TTL + deadline at the pinned instant. +- `neg_args_mismatch`: signature and TTL valid, but the commitment binds + arguments other than the supplied runtime arguments. + +A redacted (non-identity) projection verifies with `args_commitment_ok` +true and `projection_match` false: the verifier confirms the projection +is signed but makes no completeness claim. No fixture pins that shape, +but `verify_args_commitment` returns it. + +## Verifying + +``` +python tests/vectors/sep2787_attestation_v0/_check_independent.py +``` + +Exit code 0 means every case matched its expected verdict. The checker +reproduces the canonical bytes (RFC 8785 JCS), signature verification +across HS256 / ES256 / RS256, TTL evaluation against the pinned instant, +and argument-commitment verification, all without importing Vaara. + +HS256 and RS256 signatures are deterministic, so a second implementation +re-signing reproduces the stored signature exactly. ES256 is randomised, +so the ES256 fixtures are verified against the stored public key rather +than reproduced bit for bit. + +## Provenance + +Generated by `scripts/generate_sep2787_attestation_vectors.py` against +the Vaara reference implementation in `vaara.attestation.sep2787`, +aligned to the spec revision tracked in `docs/sep2787-conformance.md`. +Apache-2.0. Regenerating produces fresh asymmetric keys and signatures. diff --git a/tests/vectors/sep2787_attestation_v0/_check_independent.py b/tests/vectors/sep2787_attestation_v0/_check_independent.py new file mode 100644 index 0000000..1354cac --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/_check_independent.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Independent conformance checker for the v0 SEP-2787 attestation vectors. + +Imports only the standard library plus ``cryptography`` and ``rfc8785``. +It does not import Vaara. It reads the committed fixtures from disk and +reproduces, for each case, the three verification dimensions: signature +over the JCS-canonical envelope body, TTL against a pinned instant, and +(when runtime arguments are supplied) the step-5 argument commitment. +The results are compared against ``expected.json``. + +A second implementation that can run this file (or reproduce its logic) +demonstrates that the attestation format is consumable without depending +on Vaara. Run: +``python tests/vectors/sep2787_attestation_v0/_check_independent.py``. +Exit code 0 means every case matched its expected verdict. + +HS256 and RS256 signatures are deterministic, so a second implementation +re-signing reproduces the stored signature exactly. ES256 is randomised, +so the ES256 fixtures are verified against the stored public key rather +than reproduced bit for bit. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, padding +from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature + +HERE = Path(__file__).resolve().parent +KEYS = HERE / "keys" + +# Must match EVAL_NOW_ISO in scripts/generate_sep2787_attestation_vectors.py +# and the default clock_skew_seconds in vaara.attestation verify_attestation. +EVAL_NOW = datetime(2026, 5, 29, 10, 0, 30, tzinfo=timezone.utc).timestamp() +CLOCK_SKEW_SECONDS = 30 + +_SIGNED_KEYS = ("version", "alg", "plannerDeclared", "issuerAsserted", + "payloadDerived") + + +def _jcs(obj) -> bytes: + return rfc8785.dumps(obj) + + +def _sha256_hex(content: bytes) -> str: + return f"sha256:{hashlib.sha256(content).hexdigest()}" + + +def _signing_payload(att: dict) -> bytes: + return _jcs({k: att[k] for k in _SIGNED_KEYS}) + + +def verify_signature(att: dict) -> bool: + payload = _signing_payload(att) + alg = att["alg"] + sig = att["signature"] + if alg == "HS256": + secret = (KEYS / "hs256_secret.bin").read_bytes() + expected = hmac.new(secret, payload, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, sig) + if alg == "ES256": + pub = serialization.load_pem_public_key( + (KEYS / "es256_public.pem").read_bytes()) + if len(sig) != 128: + return False + try: + raw = bytes.fromhex(sig) + except ValueError: + return False + der = encode_dss_signature( + int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big")) + try: + pub.verify(der, payload, ec.ECDSA(hashes.SHA256())) + return True + except InvalidSignature: + return False + if alg == "RS256": + pub = serialization.load_pem_public_key( + (KEYS / "rs256_public.pem").read_bytes()) + try: + pub.verify(bytes.fromhex(sig), payload, + padding.PKCS1v15(), hashes.SHA256()) + return True + except (InvalidSignature, ValueError): + return False + return False + + +def _iso_epoch(iso: str) -> float: + if iso.endswith("Z"): + iso = iso[:-1] + "+00:00" + return datetime.fromisoformat(iso).timestamp() + + +def verify_ttl(att: dict) -> bool: + issuer = att["issuerAsserted"] + deadline = _iso_epoch(issuer["iat"]) + issuer["expSeconds"] + CLOCK_SKEW_SECONDS + return EVAL_NOW <= deadline + + +def _parse_hash_only_identity(projection: str): + try: + obj = json.loads(projection) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(obj, dict) or set(obj) != {"digest"}: + return None + digest = obj["digest"] + if not isinstance(digest, str) or not digest.startswith("sha256:"): + return None + return digest + + +def verify_args_commitment(att: dict, runtime_args): + """Return (ok, projection_match) for the single tool-call commitment. + + Mirrors vaara.attestation verify_args_commitment for the + ArgsProjection shapes the vectors use (identity, hash-only-identity, + and redacted). ArgsRef is not exercised by these fixtures. + """ + args = att["payloadDerived"]["toolCalls"][0]["args"] + if "projection" not in args: + raise ValueError("vector uses a non-projection commitment") + projection = args["projection"] + pbytes = projection.encode("utf-8") + if _sha256_hex(pbytes) != args["projectionDigest"]: + return False, None + runtime_canonical = _jcs(runtime_args) + hash_only = _parse_hash_only_identity(projection) + if hash_only is not None: + if hash_only != _sha256_hex(runtime_canonical): + return False, None + return True, True + return True, pbytes == runtime_canonical + + +def main() -> int: + failures = 0 + cases = sorted((HERE / "normative").iterdir()) + for case in cases: + if not case.is_dir(): + continue + att = json.loads((case / "attestation.json").read_text()) + expected = json.loads((case / "expected.json").read_text()) + got = { + "signature_ok": verify_signature(att), + "ttl_ok": verify_ttl(att), + } + runtime_args_path = case / "runtime_args.json" + if runtime_args_path.exists(): + ok, pm = verify_args_commitment( + att, json.loads(runtime_args_path.read_text())) + got["args_commitment_ok"] = ok + got["projection_match"] = pm + else: + got["args_commitment_ok"] = None + got["projection_match"] = None + ok = got == expected + failures += 0 if ok else 1 + print(f"[{'OK' if ok else 'FAIL'}] {case.name}: {got}") + print(f"\n{len(cases) - failures}/{len(cases)} cases matched expected.") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/vectors/sep2787_attestation_v0/keys/es256_private.pem b/tests/vectors/sep2787_attestation_v0/keys/es256_private.pem new file mode 100644 index 0000000..4c43d38 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/keys/es256_private.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgn7Ra480Xpw/qVa29 +bAkshAeH2piRk0by2GbITQPfViShRANCAAQB7CFKEXu/mVduembbmdMqAzLeBgto +OgjaUJBqx9IO1vfsGWuyIp2t4O5DtKHUCxM8xHUFStMgChXopUm+dN01 +-----END PRIVATE KEY----- diff --git a/tests/vectors/sep2787_attestation_v0/keys/es256_public.pem b/tests/vectors/sep2787_attestation_v0/keys/es256_public.pem new file mode 100644 index 0000000..c01b39d --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/keys/es256_public.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAewhShF7v5lXbnpm25nTKgMy3gYL +aDoI2lCQasfSDtb37BlrsiKdreDuQ7Sh1AsTPMR1BUrTIAoV6KVJvnTdNQ== +-----END PUBLIC KEY----- diff --git a/tests/vectors/sep2787_attestation_v0/keys/hs256_secret.bin b/tests/vectors/sep2787_attestation_v0/keys/hs256_secret.bin new file mode 100644 index 0000000..9eeb794 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/keys/hs256_secret.bin @@ -0,0 +1 @@ +BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB \ No newline at end of file diff --git a/tests/vectors/sep2787_attestation_v0/keys/rs256_private.pem b/tests/vectors/sep2787_attestation_v0/keys/rs256_private.pem new file mode 100644 index 0000000..e14677b --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/keys/rs256_private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQjjPr/QPbbiaa +eyh2muik5hnn6153q2oJzRHUn3NT+tmxk6HzvVKewvMz3jV93PORwKiwP6uFAtLI +AnI/CxQeoN+3qt/SHoSaCJ21Pf48hzJPtqQ1F+iODz3NoKVQQviBIMRNcqtpk0Hi +kUYLf7OyzAn2yCOvaX5dGwUzgqzff8KweCyAFJpnGZi/qWcsQRSTtxYJAcsfPfVz +m2OpxAfZyOegEgeFSIRHM7BiKu2wQpI3bcH3WpmhhMSHyTXUzuwP0axUU/agBc/a +ZYp8gDKmjFuwcH7lr1/QkSdht16dtBbfyGwryjIdqPWspxkrPfYAcYtuhPAcNDQ5 +foUhVUa5AgMBAAECggEAAwxMmrdsA+9CRSBFhTJw8dkGYHPGjoy8xa4mA31XMe9K +f3XuVRalI2dRjXmWMho/HPgYfXPx9/iRUVJE1NV+yqhOALwqKrYrhYJ++itEGnxQ +DYClOeMffcHTN/ypmEI6tmae0j8M6lb4P9B6k9HLpx8xI/0E++WYlTgfM9A/yJsc +0nSnFmuetpx93BgV3QoMV0UJguUyN9SPDEe14jtVOnuo5GCvE4HM0HpFKXOx3/eM +NU4ysWW9Ui05MSt7Qq6pAEVrq/UeW0nQ4UI0m3k06LjK2SSX3EMAb7UJmCQ5Hj+C +3t65iiOPPRbl9ci6u8zBIjc8kmOOehMNQJuXadI0IQKBgQDziRnp9i5RshwIkjqo +qfV7mHSe2OSn9d3ZepWM0/bA8l9gt5aGisWapCuXMStEuuvydOYbM26x0tsqx9CJ +6GE6EywZ4WZzU6v/LXlWzgd48AB+HJHyykUjosccQHLo5ZusSKVsvysjEOd2CnsF +nSaGTzKcS9N+rQeFl3CGSvw02QKBgQDbOsdmYWEKMLrAvOtB8mSQ7NUySH5k5ZaI +HTIiyFqHEKfNJpAVLvTfLdmrWZhzzSsDCYyTiwD+umzARbVFUh0GCtVINOIVTqSz +Lp7Sg39Fh2YudUaP4z6XDI3jBJx2m9tdzLuS/rgfHq4RaJB/yt9Y0mOMC1G8QyCK +dIbIkiz04QKBgQDKFlfRpH51PnYyyp0wfrB9GlqTwemkUssAf89/qBiXrchlqpKX +HKqzE040zRFZnD7U2BpmMfzGRPGng13SQmoD1gr/guIQCzurqaGJ3benrIjMHYsi +zKQo2ReeowB3sKjIgxP6tHvoQku/0Rya4Pgp9ahKuEVNVenq+mrelXTJqQKBgHD4 +vUB49fS2X8ZqlCELs98q7EJZMSj1VrkVsnxSGrR3VMFXGfCQngdULVMrfHBQTdXm +2dY+T2j00I3UXjE2u1YdxwLKnaa1PBJJ9/YrZS6DM5jFPnNvexWDlqph+e71KfoQ +QqjhMih3OuQlqjDwbl/rKvlD6/7D6MTwf3RkP16BAoGBANXwHDK6gjRVVGCj9n++ +9KIp8tbnEj9QpVueDUrmQ4BvHwa8yhMGYXRpv5C0BzpHfqeh85Tiib+WSe4ZZD4u +2qQ1/fXSYBkRlbnBkGI1X1qHOZ4/gMfTOnWpvxwDPx6AFVPHMb0nGOwPGmjuF7bn +JnYzK8ezyP9EjqOzB0rbVMBF +-----END PRIVATE KEY----- diff --git a/tests/vectors/sep2787_attestation_v0/keys/rs256_public.pem b/tests/vectors/sep2787_attestation_v0/keys/rs256_public.pem new file mode 100644 index 0000000..96a967b --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/keys/rs256_public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0I4z6/0D224mmnsodpro +pOYZ5+ted6tqCc0R1J9zU/rZsZOh871SnsLzM941fdzzkcCosD+rhQLSyAJyPwsU +HqDft6rf0h6EmgidtT3+PIcyT7akNRfojg89zaClUEL4gSDETXKraZNB4pFGC3+z +sswJ9sgjr2l+XRsFM4Ks33/CsHgsgBSaZxmYv6lnLEEUk7cWCQHLHz31c5tjqcQH +2cjnoBIHhUiERzOwYirtsEKSN23B91qZoYTEh8k11M7sD9GsVFP2oAXP2mWKfIAy +poxbsHB+5a9f0JEnYbdenbQW38hsK8oyHaj1rKcZKz32AHGLboTwHDQ0OX6FIVVG +uQIDAQAB +-----END PUBLIC KEY----- diff --git a/tests/vectors/sep2787_attestation_v0/normative/es256_projection_identity/attestation.json b/tests/vectors/sep2787_attestation_v0/normative/es256_projection_identity/attestation.json new file mode 100644 index 0000000..0e2a91e --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/es256_projection_identity/attestation.json @@ -0,0 +1,29 @@ +{ + "alg": "ES256", + "issuerAsserted": { + "alg": "ES256", + "expSeconds": 300, + "iat": "2026-05-29T10:00:00Z", + "iss": "issuer://test", + "nonce": "att-nonce-es-0002", + "secretVersion": "v1", + "sub": "agent:archiver" + }, + "payloadDerived": { + "toolCalls": [ + { + "args": { + "projection": "{\"path\":\"/archive/2024-Q3.md\",\"recursive\":false}", + "projectionDigest": "sha256:16fcac2dbb8f5d911362b041bbd7e2bb68393b45858d1414983be44a18df3dd4" + }, + "name": "delete_file", + "serverFingerprint": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + ] + }, + "plannerDeclared": { + "intent": "archive obsolete report" + }, + "signature": "cfb11c5ece9ea5f6e968ad676a4cf3b8b128d7c5c0dc6e374fb35b1f07d2b6e5876a5f96810806b1551d77e70b804f23db8e234e0e7d665911aa39cf99f33b65", + "version": 1 +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/es256_projection_identity/expected.json b/tests/vectors/sep2787_attestation_v0/normative/es256_projection_identity/expected.json new file mode 100644 index 0000000..5b954ea --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/es256_projection_identity/expected.json @@ -0,0 +1,6 @@ +{ + "args_commitment_ok": true, + "projection_match": true, + "signature_ok": true, + "ttl_ok": true +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/es256_projection_identity/runtime_args.json b/tests/vectors/sep2787_attestation_v0/normative/es256_projection_identity/runtime_args.json new file mode 100644 index 0000000..dfd6381 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/es256_projection_identity/runtime_args.json @@ -0,0 +1,4 @@ +{ + "path": "/archive/2024-Q3.md", + "recursive": false +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/hs256_digest_identity/attestation.json b/tests/vectors/sep2787_attestation_v0/normative/hs256_digest_identity/attestation.json new file mode 100644 index 0000000..dbd9ed3 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/hs256_digest_identity/attestation.json @@ -0,0 +1,29 @@ +{ + "alg": "HS256", + "issuerAsserted": { + "alg": "HS256", + "expSeconds": 300, + "iat": "2026-05-29T10:00:00Z", + "iss": "issuer://test", + "nonce": "att-nonce-hs-0001", + "secretVersion": "v1", + "sub": "agent:archiver" + }, + "payloadDerived": { + "toolCalls": [ + { + "args": { + "projection": "{\"digest\":\"sha256:16fcac2dbb8f5d911362b041bbd7e2bb68393b45858d1414983be44a18df3dd4\"}", + "projectionDigest": "sha256:7f77dfef90fdde7e68aa1f92c8a09a9cbb10751387051978e67c1198f6206c52" + }, + "name": "delete_file", + "serverFingerprint": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + ] + }, + "plannerDeclared": { + "intent": "archive obsolete report" + }, + "signature": "99123c80e9a54bea238aa1f475adf5adc76b6d94b287d12d7e56e4904c6f465c", + "version": 1 +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/hs256_digest_identity/expected.json b/tests/vectors/sep2787_attestation_v0/normative/hs256_digest_identity/expected.json new file mode 100644 index 0000000..5b954ea --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/hs256_digest_identity/expected.json @@ -0,0 +1,6 @@ +{ + "args_commitment_ok": true, + "projection_match": true, + "signature_ok": true, + "ttl_ok": true +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/hs256_digest_identity/runtime_args.json b/tests/vectors/sep2787_attestation_v0/normative/hs256_digest_identity/runtime_args.json new file mode 100644 index 0000000..dfd6381 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/hs256_digest_identity/runtime_args.json @@ -0,0 +1,4 @@ +{ + "path": "/archive/2024-Q3.md", + "recursive": false +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/neg_args_mismatch/attestation.json b/tests/vectors/sep2787_attestation_v0/normative/neg_args_mismatch/attestation.json new file mode 100644 index 0000000..0187d69 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/neg_args_mismatch/attestation.json @@ -0,0 +1,29 @@ +{ + "alg": "HS256", + "issuerAsserted": { + "alg": "HS256", + "expSeconds": 300, + "iat": "2026-05-29T10:00:00Z", + "iss": "issuer://test", + "nonce": "att-nonce-hs-0006", + "secretVersion": "v1", + "sub": "agent:archiver" + }, + "payloadDerived": { + "toolCalls": [ + { + "args": { + "projection": "{\"digest\":\"sha256:16fcac2dbb8f5d911362b041bbd7e2bb68393b45858d1414983be44a18df3dd4\"}", + "projectionDigest": "sha256:7f77dfef90fdde7e68aa1f92c8a09a9cbb10751387051978e67c1198f6206c52" + }, + "name": "delete_file", + "serverFingerprint": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + ] + }, + "plannerDeclared": { + "intent": "archive obsolete report" + }, + "signature": "cccf37330926c4ffe94ad02bce5af3a743d509853ad2c0d3f110b944659438dd", + "version": 1 +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/neg_args_mismatch/expected.json b/tests/vectors/sep2787_attestation_v0/normative/neg_args_mismatch/expected.json new file mode 100644 index 0000000..5ed61df --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/neg_args_mismatch/expected.json @@ -0,0 +1,6 @@ +{ + "args_commitment_ok": false, + "projection_match": null, + "signature_ok": true, + "ttl_ok": true +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/neg_args_mismatch/runtime_args.json b/tests/vectors/sep2787_attestation_v0/normative/neg_args_mismatch/runtime_args.json new file mode 100644 index 0000000..a3eaf16 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/neg_args_mismatch/runtime_args.json @@ -0,0 +1,4 @@ +{ + "path": "/keep/forever.md", + "recursive": false +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/neg_bad_signature/attestation.json b/tests/vectors/sep2787_attestation_v0/normative/neg_bad_signature/attestation.json new file mode 100644 index 0000000..d3c9467 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/neg_bad_signature/attestation.json @@ -0,0 +1,29 @@ +{ + "alg": "HS256", + "issuerAsserted": { + "alg": "HS256", + "expSeconds": 300, + "iat": "2026-05-29T10:00:00Z", + "iss": "issuer://test", + "nonce": "att-nonce-hs-0004", + "secretVersion": "v1", + "sub": "agent:archiver" + }, + "payloadDerived": { + "toolCalls": [ + { + "args": { + "projection": "{\"digest\":\"sha256:16fcac2dbb8f5d911362b041bbd7e2bb68393b45858d1414983be44a18df3dd4\"}", + "projectionDigest": "sha256:7f77dfef90fdde7e68aa1f92c8a09a9cbb10751387051978e67c1198f6206c52" + }, + "name": "delete_file", + "serverFingerprint": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + ] + }, + "plannerDeclared": { + "intent": "archive obsolete report" + }, + "signature": "7c912676c318911232375ef44a69f155226221a7df157d0f749a8fbf60ffe4b0", + "version": 1 +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/neg_bad_signature/expected.json b/tests/vectors/sep2787_attestation_v0/normative/neg_bad_signature/expected.json new file mode 100644 index 0000000..c3fe853 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/neg_bad_signature/expected.json @@ -0,0 +1,6 @@ +{ + "args_commitment_ok": true, + "projection_match": true, + "signature_ok": false, + "ttl_ok": true +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/neg_bad_signature/runtime_args.json b/tests/vectors/sep2787_attestation_v0/normative/neg_bad_signature/runtime_args.json new file mode 100644 index 0000000..dfd6381 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/neg_bad_signature/runtime_args.json @@ -0,0 +1,4 @@ +{ + "path": "/archive/2024-Q3.md", + "recursive": false +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/neg_expired/attestation.json b/tests/vectors/sep2787_attestation_v0/normative/neg_expired/attestation.json new file mode 100644 index 0000000..3707c1d --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/neg_expired/attestation.json @@ -0,0 +1,29 @@ +{ + "alg": "ES256", + "issuerAsserted": { + "alg": "ES256", + "expSeconds": 300, + "iat": "2026-05-29T08:00:00Z", + "iss": "issuer://test", + "nonce": "att-nonce-es-0005", + "secretVersion": "v1", + "sub": "agent:archiver" + }, + "payloadDerived": { + "toolCalls": [ + { + "args": { + "projection": "{\"path\":\"/archive/2024-Q3.md\",\"recursive\":false}", + "projectionDigest": "sha256:16fcac2dbb8f5d911362b041bbd7e2bb68393b45858d1414983be44a18df3dd4" + }, + "name": "delete_file", + "serverFingerprint": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + ] + }, + "plannerDeclared": { + "intent": "archive obsolete report" + }, + "signature": "93aa73b56e2323c20abcfad5a22a245293e45136986957a648a146450d944a5573c5fd802b9d1ffa0642911d8fa3016731acbdcdc0474dbe5a5447b9cf186ef5", + "version": 1 +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/neg_expired/expected.json b/tests/vectors/sep2787_attestation_v0/normative/neg_expired/expected.json new file mode 100644 index 0000000..88c271c --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/neg_expired/expected.json @@ -0,0 +1,6 @@ +{ + "args_commitment_ok": true, + "projection_match": true, + "signature_ok": true, + "ttl_ok": false +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/neg_expired/runtime_args.json b/tests/vectors/sep2787_attestation_v0/normative/neg_expired/runtime_args.json new file mode 100644 index 0000000..dfd6381 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/neg_expired/runtime_args.json @@ -0,0 +1,4 @@ +{ + "path": "/archive/2024-Q3.md", + "recursive": false +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/rs256_signature_ttl_only/attestation.json b/tests/vectors/sep2787_attestation_v0/normative/rs256_signature_ttl_only/attestation.json new file mode 100644 index 0000000..24313e0 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/rs256_signature_ttl_only/attestation.json @@ -0,0 +1,29 @@ +{ + "alg": "RS256", + "issuerAsserted": { + "alg": "RS256", + "expSeconds": 300, + "iat": "2026-05-29T10:00:00Z", + "iss": "issuer://test", + "nonce": "att-nonce-rs-0003", + "secretVersion": "v1", + "sub": "agent:archiver" + }, + "payloadDerived": { + "toolCalls": [ + { + "args": { + "projection": "{\"path\":\"/archive/2024-Q3.md\",\"recursive\":false}", + "projectionDigest": "sha256:16fcac2dbb8f5d911362b041bbd7e2bb68393b45858d1414983be44a18df3dd4" + }, + "name": "delete_file", + "serverFingerprint": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + ] + }, + "plannerDeclared": { + "intent": "archive obsolete report" + }, + "signature": "19003aac49c542741f8cda9af5dea5c9b15ec024eac884b7e532bfe04fe54b8e18f34022029ab9e171eaa30c1e6da0884a5222c2065ea4f4b05e8f32e1ace96ed1712724369104dbb0fe81f5e348a3ab13620515a9ba0b0556415ed2d1e6b6edb6cda1bd0f3a16586a74a782f55b293f55db6ae83f71f181374148a2001787857b27754a9394b3acba832bbab66f236f91cfb376ad494ad38628075df56a7ed8f3e98e6bad6c6a88a00363d5a3cfea9ee72cab9168ddecbc013752ac8ee7bb43c27dc9ddee0f917397f5fedf2dfc6907272e48c166602b8db0b3b4cfef7356c31fcffea4d910d7d2f811e86a3bacfda15a3d08ddd67542a54ecc1240e374f87b", + "version": 1 +} diff --git a/tests/vectors/sep2787_attestation_v0/normative/rs256_signature_ttl_only/expected.json b/tests/vectors/sep2787_attestation_v0/normative/rs256_signature_ttl_only/expected.json new file mode 100644 index 0000000..dd27e16 --- /dev/null +++ b/tests/vectors/sep2787_attestation_v0/normative/rs256_signature_ttl_only/expected.json @@ -0,0 +1,6 @@ +{ + "args_commitment_ok": null, + "projection_match": null, + "signature_ok": true, + "ttl_ok": true +}