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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
2 changes: 1 addition & 1 deletion clients/ts/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
13 changes: 8 additions & 5 deletions docs/sep2787-conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
174 changes: 174 additions & 0 deletions scripts/generate_sep2787_attestation_vectors.py
Original file line number Diff line number Diff line change
@@ -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")

Check failure

Code scanning / CodeQL

Clear-text storage of sensitive information High

This expression stores
sensitive data (secret)
as clear text.
Comment thread
vaaraio marked this conversation as resolved.
Dismissed


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)

Check failure

Code scanning / CodeQL

Clear-text storage of sensitive information High

This expression stores
sensitive data (secret)
as clear text.
Comment thread
vaaraio marked this conversation as resolved.
Dismissed
(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()
4 changes: 2 additions & 2 deletions server-vaara-server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/vaara/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
oversight.
"""

__version__ = "0.44.0"
__version__ = "0.45.0"

from vaara.pipeline import InterceptionPipeline, InterceptionResult

Expand Down
23 changes: 22 additions & 1 deletion src/vaara/integrations/_mcp_upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading