diff --git a/.gitignore b/.gitignore index df155b6..f826704 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ application_*.pdf outbound_*.md site.py.live +# CodeGraph index (local dev tool, not part of the repo) +.codegraph/ + # Bench output (PAIR runs, dist-shift, vLLM logs). Reproducible by rerun. tests/adversarial/v031/ .parachute/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d571b3..963bd3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,42 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.43.0] - 2026-05-29 + +**Theme: proxy pairing -- SEP-2787 request attestation and execution receipt emitted per tools/call.** + +### Added +- `src/vaara/integrations/_mcp_attest.py`: `AttestPairEmitter`, the paired + SEP-2787 attestation and execution-receipt emitter for the MCP proxy. Each + allowed `tools/call` writes two JSON files to a configurable receipts + directory: `{counter}-{nonce[:8]}-attest.json` (request attestation) and + `{counter}-{nonce[:8]}-receipt.json` (execution receipt). The pair is + cryptographically linked: the receipt carries a `backLink` digest over the + full attestation wire bytes, so a verifier can confirm they belong together. +- `--attest-signing-key PATH` and `--attest-receipts-dir DIR` flags on + `vaara-mcp-proxy`. Off by default. Key type is auto-detected: EC P-256 PEM + uses ES256, RSA PEM uses RS256, raw bytes file uses HS256. For ES256 and + RS256 a `pubkey.pem` is written to the receipts directory so external + verifiers need only the public key. +- `serverFingerprint` in each attestation starts as a SHA-256 of the upstream + command string (`cmd:sha256:{hex}`) and upgrades to a SHA-256 of the + canonical JSON of the tools list (`manifest:sha256:{hex}`) on the first + `tools/list` response, binding the exact capability set the proxy presented + to the agent. +- `X-Vaara-Intent` HTTP request header: operators can supply a richer intent + label per call. stdio transport falls back to the derived + `tools/call/{tool_name}` string. +- `issuerAsserted.iss` is always `"vaara-mcp-proxy"`. `sub` is + `"{tenant_id}/{upstream_name}"` when a tenant is set, else + `"{upstream_name}"`. Reuses the SEP-2787 and receipt signing stack unchanged + (HS256 / ES256 / RS256, RFC 8785 JCS canonicalization): a verifier that + already checks SEP-2787 signatures needs no new crypto for the paired + receipts. +- 17 tests in `tests/test_integrations_mcp_proxy_attest.py` covering pairing, + SEP-2787 signature verification, back-link integrity, manifest fingerprint + upgrade, intent override via ContextVar, errored-receipt pairing when the + upstream raises, and `AttestConfigError` handling. + ## [0.42.0] - 2026-05-29 **Theme: execution receipts, the post-execution sibling of SEP-2787.** diff --git a/README.md b/README.md index 7c29040..c654742 100644 --- a/README.md +++ b/README.md @@ -150,25 +150,6 @@ const r = await vaara.score({ tool_name: "tx.transfer", agent_id: "agent-007", b if (r.decision === "deny") throw new Error("blocked"); ``` -## Policy modes - -Four preset operating points for the risk thresholds, shaped like CPU power profiles: - -- `eco` (escalate 0.40, deny 0.60). Tight deny threshold cuts agent loops short on borderline risk. Pair with regex-first gating to short-circuit before any model forward pass. -- `balanced` (0.55, 0.85). Vaara's default behavior. -- `performance` (0.70, 0.92). Looser thresholds let more through. For high-throughput pipelines where the deployer keeps tight action-class overrides on the few classes that matter. -- `strict` (0.30, 0.55). Escalate-on-doubt. For incident response, audit prep, or production lockdown windows. - -Each mode emits a minimal valid Vaara policy document with `thresholds.default` set, ready for the deployer to fill in action classes, sequences, and escalation routes. - -```bash -vaara mode list -vaara mode show balanced -vaara mode emit strict --format yaml --output policy.yaml -``` - -The emitted document round-trips through `vaara.policy.from_dict`, `from_json`, and `from_yaml` like any other policy artifact. - ## MCP proxy (Vaara as a transparent governance layer) `vaara.integrations.mcp_proxy.VaaraMCPProxy` sits between an MCP client (Claude Code, Cursor, any MCP-capable host) and an upstream MCP server. Every `tools/call` from the client routes through Vaara's interception pipeline before reaching the upstream. Allowed calls forward transparently and report the upstream outcome back to the scorer. Blocked calls return an MCP `isError: true` response with the block reason. The initialization handshake and `notifications/*` forward unchanged. `tools/list`, `resources/list`, `resources/read`, `prompts/list`, and `prompts/get` route through the operator perimeter before reaching the client or upstream. @@ -208,6 +189,8 @@ The proxy accepts repeatable `--allow-tool NAME` / `--deny-tool NAME`, `--allow- OVERT envelopes per governed interaction turn on with `--overt-signing-key`, `--overt-operator-key`, `--overt-receipts-dir`. Wire format and verifier covered in the [OVERT 1.0 attestation](#overt-10-attestation) section below. Long-running tools' `notifications/progress` and `notifications/message` route through the same audit pair and OVERT envelope, correlated to the originating call via `_meta.progressToken`. +SEP-2787 request attestation paired with an execution receipt turns on with `--attest-signing-key PATH` and `--attest-receipts-dir DIR`. Each allowed `tools/call` writes a `{n}-attest.json` (pre-execution SEP-2787 envelope) and a `{n}-receipt.json` (post-execution outcome record with a `backLink` digest over the attestation). Key type is auto-detected from the file: EC P-256 PEM uses ES256, RSA PEM uses RS256, raw bytes uses HS256. An operator-supplied `X-Vaara-Intent` HTTP header overrides the derived `tools/call/{tool_name}` intent label. The `serverFingerprint` field in the attestation starts as a hash of the upstream command string and upgrades to a hash of the upstream's `tools/list` response on first use, binding the exact capability set the proxy presented. See [docs/execution-receipts.md](docs/execution-receipts.md) for the receipt format. + Worked examples: - [`examples/github-mcp-proxy-demo/`](examples/github-mcp-proxy-demo/): Vaara in front of [`github/github-mcp-server`](https://github.com/github/github-mcp-server), 42 tools, hash-chained audit trail recorded end-to-end. diff --git a/clients/ts/package.json b/clients/ts/package.json index 97cd471..bf88ded 100644 --- a/clients/ts/package.json +++ b/clients/ts/package.json @@ -1,6 +1,6 @@ { "name": "@vaara/client", - "version": "0.42.0", + "version": "0.43.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/pyproject.toml b/pyproject.toml index 09cccf3..5aa6812 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vaara" -version = "0.42.0" +version = "0.43.0" description = "Adaptive AI Agent Execution Layer for risk scoring, audit trails, and regulatory compliance" requires-python = ">=3.10" license = "Apache-2.0" diff --git a/server-vaara-server.json b/server-vaara-server.json index 8349ef6..8eb5595 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.42.0", + "version": "0.43.0", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "vaara", - "version": "0.42.0", + "version": "0.43.0", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/server.json b/server.json index 932acf4..124037e 100644 --- a/server.json +++ b/server.json @@ -8,13 +8,13 @@ "url": "https://github.com/vaaraio/vaara", "source": "github" }, - "version": "0.42.0", + "version": "0.43.0", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "vaara", - "version": "0.42.0", + "version": "0.43.0", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/src/vaara/__init__.py b/src/vaara/__init__.py index 2dd0c7c..084df1d 100644 --- a/src/vaara/__init__.py +++ b/src/vaara/__init__.py @@ -6,7 +6,7 @@ oversight. """ -__version__ = "0.42.0" +__version__ = "0.43.0" from vaara.pipeline import InterceptionPipeline, InterceptionResult diff --git a/src/vaara/integrations/_mcp_attest.py b/src/vaara/integrations/_mcp_attest.py new file mode 100644 index 0000000..4899edd --- /dev/null +++ b/src/vaara/integrations/_mcp_attest.py @@ -0,0 +1,332 @@ +"""SEP-2787 attestation + execution-receipt pairing for the Vaara MCP proxy. + +Internal helper. Off unless the operator wires the proxy with a signing key +and receipts directory. When active, every allowed ``tools/call`` writes two +JSON files to the receipts directory: + + {counter:010d}-{nonce[:8]}-attest.json -- SEP-2787 request attestation + {counter:010d}-{nonce[:8]}-receipt.json -- execution receipt via backLink + +Signing modes and key auto-detection: + +- PEM file with EC P-256 key -> ES256 (recommended) +- PEM file with RSA key -> RS256 +- Raw bytes file (no PEM header) -> HS256 + +``iss`` is always ``"vaara-mcp-proxy"``. ``sub`` is +``"{tenant_id}/{upstream_name}"`` when a tenant is present, else just +``"{upstream_name}"``. + +``serverFingerprint`` starts as ``cmd:sha256:{hex}`` (SHA-256 of the upstream +command string, computed at construction). The first ``tools/list`` response +per upstream upgrades it to ``manifest:sha256:{hex}`` (SHA-256 of the +canonical JSON of the effective tools array), binding the exact capability set +the proxy presented to the agent. + +Intent defaults to ``tools/call/{tool_name}``. Operators can supply a richer +label via the ``X-Vaara-Intent`` HTTP request header; stdio transport uses the +derived default. + +For ES256 / RS256, a ``pubkey.pem`` (SubjectPublicKeyInfo) is written to the +receipts directory so external verifiers need only the public key. + +Internal module. Public surface: ``--attest-signing-key`` / +``--attest-receipts-dir`` on ``vaara-mcp-proxy``. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import threading +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +_ISS = "vaara-mcp-proxy" + + +class AttestConfigError(RuntimeError): + """Operator-side attestation config is incomplete or unusable.""" + + +class AttestPairEmitter: + """Per-proxy SEP-2787 attestation + execution-receipt emitter. + + One instance per proxy process. Owns the signing key, fingerprint + table, and monotonic counter. Thread-safe. + """ + + def __init__( + self, + *, + signing_key: Any, + alg: str, + receipts_dir: Path, + secret_version: str, + upstream_commands: dict[str, list[str]], + exp_seconds: int = 300, + ) -> None: + from vaara.attestation._sep2787_types import VALID_ALGS + if alg not in VALID_ALGS: + raise AttestConfigError(f"unsupported alg: {alg!r}; use HS256, ES256, or RS256") + self._signing_key = signing_key + self._alg = alg + self._receipts_dir = Path(receipts_dir) + self._receipts_dir.mkdir(parents=True, exist_ok=True) + self._secret_version = secret_version + self._exp_seconds = exp_seconds + self._counter = 0 + self._lock = threading.Lock() + # cmd-hash fingerprints pre-computed at construction; upgraded to + # manifest-hash on first tools/list response per upstream. + self._fingerprints: dict[str, str] = { + name: _cmd_hash(cmd) for name, cmd in upstream_commands.items() + } + self._write_pubkey_pin() + + @property + def receipts_dir(self) -> Path: + return self._receipts_dir + + def update_manifest_fingerprint( + self, upstream_name: str, tools_list_response: dict + ) -> None: + """Upgrade from cmd-hash to manifest-hash on first tools/list response. + + Hashes the canonical JSON of the tools array from the response (which + may be post-operator-filter, binding the effective capability set the + proxy presents to agents). Idempotent after the first upgrade. + """ + with self._lock: + current = self._fingerprints.get(upstream_name, "") + if current.startswith("manifest:sha256:"): + return + try: + from vaara.attestation._sep2787_canonical import canonical_json + result = tools_list_response.get("result") or {} + tools = result.get("tools") or [] + manifest_bytes = canonical_json({"tools": tools}) + h = hashlib.sha256(manifest_bytes).hexdigest() + with self._lock: + self._fingerprints[upstream_name] = f"manifest:sha256:{h}" + logger.debug( + "Manifest fingerprint for upstream %r: sha256:...%s", upstream_name, h[-8:] + ) + except Exception: + logger.exception( + "Failed to capture manifest fingerprint for upstream %r", upstream_name + ) + + def fingerprint_for(self, upstream_name: str) -> str: + """Best available fingerprint: manifest if captured, cmd-hash otherwise.""" + with self._lock: + return self._fingerprints.get( + upstream_name, f"cmd:sha256:unknown-{upstream_name}" + ) + + def emit_attestation( + self, + *, + tool_name: str, + arguments: dict, + upstream_name: str, + tenant_id: str, + intent_override: str = "", + ) -> "Optional[tuple[Any, int]]": + """Build, sign, and persist a SEP-2787 attestation. + + Returns ``(Attestation, counter)`` on success, ``None`` on failure. + Failures are logged and swallowed: attestation must not block traffic. + The counter is passed to ``emit_receipt`` for paired filenames. + """ + try: + from vaara.attestation.sep2787 import emit_attestation as _emit, make_args_digest + from vaara.attestation._sep2787_types import ( + PayloadDerived, PlannerDeclared, ToolCallBinding, + ) + + intent = intent_override.strip() if intent_override else f"tools/call/{tool_name}" + sub = f"{tenant_id}/{upstream_name}" if tenant_id else upstream_name + fingerprint = self.fingerprint_for(upstream_name) + args_commitment = make_args_digest(arguments) + + planner = PlannerDeclared(intent=intent) + payload = PayloadDerived( + tool_calls=(ToolCallBinding( + name=tool_name, + server_fingerprint=fingerprint, + args=args_commitment, + ),), + ) + + with self._lock: + self._counter += 1 + counter = self._counter + + attestation = _emit( + planner_declared=planner, + payload_derived=payload, + iss=_ISS, + sub=sub, + secret_version=self._secret_version, + alg=self._alg, + signing_material=self._signing_key, + exp_seconds=self._exp_seconds, + ) + + nonce_tag = attestation.issuer_asserted.nonce[:8] + path = self._receipts_dir / f"{counter:010d}-{nonce_tag}-attest.json" + path.write_text(json.dumps(attestation.to_dict(), indent=2), encoding="utf-8") + logger.debug("Attestation %s tool=%r upstream=%r", path.name, tool_name, upstream_name) + return (attestation, counter) + except Exception: + logger.exception("SEP-2787 attestation emission failed for tool=%r", tool_name) + return None + + def emit_receipt( + self, + *, + attestation: Any, + counter: int, + outcome_severity: float, + upstream_name: str, + tenant_id: str, + ) -> None: + """Build, sign, and persist an execution receipt paired to the attestation. + + ``outcome_severity == 0.0`` maps to ``executed``; anything above maps + to ``errored``. Failures are logged and swallowed. + """ + try: + from vaara.attestation.receipt import emit_receipt as _emit_receipt, make_back_link + from vaara.attestation._receipt_types import OutcomeDerived + from vaara.attestation._sep2787_canonical import now_iso8601 + + status: str = "errored" if outcome_severity > 0.0 else "executed" + back_link = make_back_link(attestation) + outcome = OutcomeDerived( + status=status, + completed_at=now_iso8601(), + ) + sub = f"{tenant_id}/{upstream_name}" if tenant_id else upstream_name + + receipt = _emit_receipt( + back_link=back_link, + outcome_derived=outcome, + iss=_ISS, + sub=sub, + secret_version=self._secret_version, + alg=self._alg, + signing_material=self._signing_key, + ) + + nonce_tag = attestation.issuer_asserted.nonce[:8] + path = self._receipts_dir / f"{counter:010d}-{nonce_tag}-receipt.json" + path.write_text(json.dumps(receipt.to_dict(), indent=2), encoding="utf-8") + logger.debug("Receipt %s status=%s", path.name, status) + except Exception: + logger.exception("Execution receipt emission failed") + + def _write_pubkey_pin(self) -> None: + if self._alg == "HS256": + return + try: + from cryptography.hazmat.primitives import serialization + pub = self._signing_key.public_key() + pem_bytes = pub.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + (self._receipts_dir / "pubkey.pem").write_bytes(pem_bytes) + except Exception: + logger.warning("Failed to write pubkey.pem to receipts directory") + + +def _cmd_hash(command: list[str]) -> str: + cmd_str = " ".join(command) + return "cmd:sha256:" + hashlib.sha256(cmd_str.encode("utf-8")).hexdigest() + + +def build_attest_emitter( + *, + signing_key_path: Path, + receipts_dir: Path, + upstream_commands: dict[str, list[str]], + secret_version: Optional[str] = None, + exp_seconds: int = 300, +) -> AttestPairEmitter: + """Load signing key from path and return an ``AttestPairEmitter``. + + Key type auto-detection: + - PEM file (EC P-256) -> ES256 + - PEM file (RSA) -> RS256 + - Raw bytes file (no PEM header) -> HS256 + + ``secret_version`` defaults to the first 8 hex chars of the SHA-256 of + the public-key DER (PEM keys) or raw bytes (HS256), so key rotation is + automatically reflected. + + Raises ``AttestConfigError`` if the key is missing, unusable, or if the + ``attestation`` extra is not installed. + """ + try: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey + from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey + except ImportError as exc: + raise AttestConfigError( + "vaara-mcp-proxy --attest-* flags require the attestation extra. " + "Install with: pip install 'vaara[attestation]'" + ) from exc + + key_path = Path(signing_key_path).expanduser() + if not key_path.is_file(): + raise AttestConfigError(f"--attest-signing-key file not found: {key_path}") + + raw = key_path.read_bytes() + + if raw.lstrip().startswith(b"-----BEGIN"): + try: + key = serialization.load_pem_private_key(raw, password=None) + except Exception as exc: + raise AttestConfigError( + f"--attest-signing-key is not a usable PEM private key: {exc}" + ) from exc + if isinstance(key, EllipticCurvePrivateKey): + alg: str = "ES256" + elif isinstance(key, RSAPrivateKey): + alg = "RS256" + else: + raise AttestConfigError( + "--attest-signing-key must be EC P-256 (ES256) or RSA (RS256). " + "Generate: openssl ecparam -genkey -name prime256v1 | " + "openssl pkcs8 -topk8 -nocrypt -out attest_key.pem" + ) + signing_material: Any = key + if secret_version is None: + pub_der = key.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + secret_version = hashlib.sha256(pub_der).hexdigest()[:8] + else: + if len(raw) < 16: + raise AttestConfigError( + f"--attest-signing-key raw bytes must be at least 16 bytes; got {len(raw)}" + ) + alg = "HS256" + signing_material = raw + if secret_version is None: + secret_version = hashlib.sha256(raw).hexdigest()[:8] + + return AttestPairEmitter( + signing_key=signing_material, + alg=alg, + receipts_dir=Path(receipts_dir).expanduser(), + secret_version=secret_version, + upstream_commands=upstream_commands, + exp_seconds=exp_seconds, + ) diff --git a/src/vaara/integrations/mcp_proxy.py b/src/vaara/integrations/mcp_proxy.py index 685b572..c182175 100644 --- a/src/vaara/integrations/mcp_proxy.py +++ b/src/vaara/integrations/mcp_proxy.py @@ -43,6 +43,11 @@ NotificationRouter, StdioRouter, ) +from vaara.integrations._mcp_attest import ( + AttestConfigError, + AttestPairEmitter, + build_attest_emitter, +) from vaara.integrations._mcp_overt import ( OVERTConfigError, OVERTReceiptEmitter, @@ -84,6 +89,11 @@ _REQUEST_SESSION: contextvars.ContextVar[str] = contextvars.ContextVar( "vaara_mcp_session", default="", ) +# v0.43 proxy pairing: HTTP transport sets this from X-Vaara-Intent per request. +# Empty default means intent falls back to "tools/call/{tool_name}" in the emitter. +_REQUEST_INTENT: contextvars.ContextVar[str] = contextvars.ContextVar( + "vaara_mcp_intent", default="", +) def _safe_log(value: Any, max_len: int = 200) -> str: """Sanitise a user-supplied string for safe logging. @@ -123,6 +133,7 @@ def __init__( prompt_allowlist: Optional[set[str]] = None, prompt_denylist: Optional[set[str]] = None, overt_emitter: Optional[OVERTReceiptEmitter] = None, + attest_emitter: Optional[AttestPairEmitter] = None, upstreams: Optional[dict[str, list[str]]] = None, router: Optional[NotificationRouter] = None, ) -> None: @@ -154,6 +165,7 @@ def __init__( ) self._stdout_lock = threading.Lock() self._overt = overt_emitter + self._attest = attest_emitter # Notification router. stdio default writes through the shared stdout # lock; HTTP transport swaps in HttpRouter in run_http(). The router is # the only surface allowed to deliver upstream-initiated notifications @@ -353,6 +365,7 @@ async def mcp_endpoint( x_vaara_tenant: Optional[str] = Header(default=None, alias="X-Vaara-Tenant"), x_vaara_upstream: Optional[str] = Header(default=None, alias="X-Vaara-Upstream"), 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: # 1 MiB cap on a single MCP JSON-RPC message. Real tool calls and # responses fit comfortably; anything larger is either a misuse or @@ -455,6 +468,7 @@ async def mcp_endpoint( upstream_token = _REQUEST_UPSTREAM.set(upstream_name) tenant_token = _REQUEST_TENANT.set((x_vaara_tenant or "").strip()) session_token = _REQUEST_SESSION.set(session_value) + intent_token = _REQUEST_INTENT.set((x_vaara_intent or "").strip()) try: if isinstance(payload, dict) and "id" not in payload: try: @@ -468,6 +482,7 @@ async def mcp_endpoint( _REQUEST_UPSTREAM.reset(upstream_token) _REQUEST_TENANT.reset(tenant_token) _REQUEST_SESSION.reset(session_token) + _REQUEST_INTENT.reset(intent_token) @app.get("/mcp") async def mcp_sse_endpoint( @@ -702,9 +717,14 @@ def _handle_request(self, request: Any) -> dict: return self._error_response(req_id, -32603, f"Upstream unavailable: {e}") def _handle_tools_list(self, request: dict) -> dict: - return self._handle_list( + response = self._handle_list( request, "tools", "name", self._allowlist, self._denylist, ) + if self._attest is not None: + self._attest.update_manifest_fingerprint( + _REQUEST_UPSTREAM.get(), response + ) + return response def _handle_list( self, @@ -820,6 +840,15 @@ def _handle_tools_call(self, request: dict) -> dict: } request_id = request.get("id") upstream_name = _REQUEST_UPSTREAM.get() + attest_pair = None + if self._attest is not None: + attest_pair = self._attest.emit_attestation( + tool_name=tool_name, + arguments=arguments, + upstream_name=upstream_name, + tenant_id=_REQUEST_TENANT.get(), + intent_override=_REQUEST_INTENT.get(), + ) with self._inflight_lock: if progress_token is not None: self._inflight_progress[progress_token] = ( @@ -831,15 +860,29 @@ def _handle_tools_call(self, request: dict) -> dict: ) if request_id is not None: self._inflight_requests[request_id] = upstream_name + # Default to failure severity so a paired receipt is still emitted + # (errored) if the upstream raises before returning a response. The + # attestation was already written above; pairing it with a receipt on + # every path keeps the evidence trail complete with no orphans. + outcome_severity = 1.0 try: upstream_response = self._upstream.request(request) + outcome_severity = self._severity_from_response(upstream_response) finally: with self._inflight_lock: if progress_token is not None: self._inflight_progress.pop(progress_token, None) if request_id is not None: self._inflight_requests.pop(request_id, None) - outcome_severity = self._severity_from_response(upstream_response) + if self._attest is not None and attest_pair is not None: + _attestation, _counter = attest_pair + self._attest.emit_receipt( + attestation=_attestation, + counter=_counter, + outcome_severity=outcome_severity, + upstream_name=upstream_name, + tenant_id=_REQUEST_TENANT.get(), + ) try: self._pipeline.report_outcome( action_id=result.action_id, outcome_severity=outcome_severity, @@ -1300,6 +1343,16 @@ def main(argv: Optional[list[str]] = None) -> None: parser.add_argument("--deny-prompt", action="append", default=[], dest="deny_prompts", help="Filter this prompt name from prompts/list and reject any " "prompts/get to it (repeatable). Denylist wins on overlap.") + parser.add_argument("--attest-signing-key", type=Path, default=None, + help="PEM private key (EC P-256 = ES256, RSA = RS256) or raw " + "bytes file (HS256) for SEP-2787 attestation + receipt " + "pairing. Off when absent. Generate EC key: openssl ecparam " + "-genkey -name prime256v1 | openssl pkcs8 -topk8 -nocrypt " + "-out attest_key.pem") + parser.add_argument("--attest-receipts-dir", type=Path, default=None, + help="Directory to write paired attestation + receipt JSON files " + "({n}-attest.json / {n}-receipt.json). Required when " + "--attest-signing-key is set.") parser.add_argument("--overt-signing-key", type=Path, default=None, help="Ed25519 PEM private key used to sign OVERT 1.0 Base " "Envelopes for every governed MCP interaction. Off when " @@ -1340,6 +1393,8 @@ def main(argv: Optional[list[str]] = None) -> None: "github=github-mcp-server` or `--upstream npx`).", ) + attest_emitter = _build_attest_emitter_from_args(args, upstreams=upstreams) + legacy_single = ( list(next(iter(upstreams.values()))) if len(upstreams) == 1 else None ) @@ -1354,6 +1409,7 @@ def main(argv: Optional[list[str]] = None) -> None: prompt_allowlist=prompt_allow, prompt_denylist=prompt_deny if prompt_deny else None, overt_emitter=overt_emitter, + attest_emitter=attest_emitter, ) try: if args.transport == "http": @@ -1411,6 +1467,42 @@ def _parse_upstream_specs( return upstreams +def _build_attest_emitter_from_args( + args: argparse.Namespace, + *, + upstreams: dict[str, list[str]], +) -> Optional[AttestPairEmitter]: + """Construct the attestation pair emitter from CLI args, or None if not configured. + + Off when --attest-signing-key is absent. If signing-key is set, + --attest-receipts-dir must also be present, or the proxy refuses to start. + """ + if args.attest_signing_key is None: + if args.attest_receipts_dir is not None: + print( + "vaara-mcp-proxy: --attest-receipts-dir has no effect without " + "--attest-signing-key. Exiting.", + file=sys.stderr, + ) + sys.exit(2) + return None + if args.attest_receipts_dir is None: + print( + "vaara-mcp-proxy: --attest-signing-key requires --attest-receipts-dir.", + file=sys.stderr, + ) + sys.exit(2) + try: + return build_attest_emitter( + signing_key_path=args.attest_signing_key, + receipts_dir=args.attest_receipts_dir, + upstream_commands=upstreams, + ) + except AttestConfigError as exc: + print(f"vaara-mcp-proxy: {exc}", file=sys.stderr) + sys.exit(2) + + def _build_overt_emitter_from_args( args: argparse.Namespace, *, policy_hash: bytes, ) -> Optional[OVERTReceiptEmitter]: diff --git a/tests/test_integrations_mcp_proxy_attest.py b/tests/test_integrations_mcp_proxy_attest.py new file mode 100644 index 0000000..0169a9b --- /dev/null +++ b/tests/test_integrations_mcp_proxy_attest.py @@ -0,0 +1,345 @@ +"""Smoke tests for SEP-2787 attestation + receipt pairing in VaaraMCPProxy. + +Mirrors the structure of test_integrations_mcp_proxy_overt.py. Uses HS256 +(raw bytes key) for all tests to avoid PEM generation overhead in fixtures; +the signing stack is tested end-to-end in test_execution_receipt.py and the +SEP-2787 vector suite. +""" + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +# Attestation rides the SEP-2787 stack: RFC 8785 canonicalization (rfc8785) +# and the signing backend (cryptography), both in the `attestation` extra. +# Skip the whole module when the extra is absent, matching the sibling +# attestation suites; CI's base test job does not install it. +pytest.importorskip("rfc8785") +pytest.importorskip("cryptography") + + +@pytest.fixture +def attest_key(tmp_path: Path) -> Path: + p = tmp_path / "attest.key" + p.write_bytes(b"x" * 32) + return p + + +@pytest.fixture +def attest_receipts_dir(tmp_path: Path) -> Path: + d = tmp_path / "attest_receipts" + d.mkdir() + return d + + +@pytest.fixture +def emitter(attest_key: Path, attest_receipts_dir: Path) -> Any: + from vaara.integrations._mcp_attest import build_attest_emitter + return build_attest_emitter( + signing_key_path=attest_key, + receipts_dir=attest_receipts_dir, + upstream_commands={"default": ["echo"]}, + ) + + +def _make_proxy(monkeypatch, *, emitter, **kwargs): + from vaara.integrations import mcp_proxy + from vaara.pipeline import InterceptionPipeline + from vaara.audit.trail import AuditTrail + + trail = AuditTrail(on_record=lambda _r: None) + pipeline = InterceptionPipeline(trail=trail) + monkeypatch.setattr( + "vaara.integrations._mcp_upstream.UpstreamMCPClient.__init__", + lambda self, command, **kw: None, + ) + p = mcp_proxy.VaaraMCPProxy( + upstream_command=["echo"], + pipeline=pipeline, + attest_emitter=emitter, + **kwargs, + ) + mock_upstream = MagicMock() + mock_upstream.request.return_value = { + "jsonrpc": "2.0", "id": 1, + "result": {"content": [{"type": "text", "text": "ok"}]}, + } + p._upstream = mock_upstream + return p, pipeline + + +def _attests(receipts_dir: Path) -> list[dict]: + return [ + json.loads(f.read_text()) + for f in sorted(receipts_dir.glob("*-attest.json")) + ] + + +def _receipts(receipts_dir: Path) -> list[dict]: + return [ + json.loads(f.read_text()) + for f in sorted(receipts_dir.glob("*-receipt.json")) + ] + + +# --------------------------------------------------------------------------- +# Basic on/off +# --------------------------------------------------------------------------- + +def test_attest_disabled_by_default(monkeypatch): + from vaara.integrations import mcp_proxy + from vaara.pipeline import InterceptionPipeline + from vaara.audit.trail import AuditTrail + monkeypatch.setattr( + "vaara.integrations._mcp_upstream.UpstreamMCPClient.__init__", + lambda self, command, **kw: None, + ) + trail = AuditTrail(on_record=lambda _r: None) + pipeline = InterceptionPipeline(trail=trail) + p = mcp_proxy.VaaraMCPProxy(upstream_command=["echo"], pipeline=pipeline) + assert p._attest is None + + +def test_allowed_tool_call_writes_attest_and_receipt_pair( + monkeypatch, emitter, attest_receipts_dir +): + p, _ = _make_proxy(monkeypatch, emitter=emitter) + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "read_file", "arguments": {"path": "/tmp/x"}}, + }) + attests = _attests(attest_receipts_dir) + receipts = _receipts(attest_receipts_dir) + assert len(attests) == 1 + assert len(receipts) == 1 + + +def test_pair_files_share_nonce_prefix(monkeypatch, emitter, attest_receipts_dir): + p, _ = _make_proxy(monkeypatch, emitter=emitter) + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "search", "arguments": {}}, + }) + attest_files = sorted(attest_receipts_dir.glob("*-attest.json")) + receipt_files = sorted(attest_receipts_dir.glob("*-receipt.json")) + assert len(attest_files) == 1 and len(receipt_files) == 1 + # Both filenames share the same {counter:010d}-{nonce[:8]} prefix. + assert attest_files[0].name[:19] == receipt_files[0].name[:19] + + +# --------------------------------------------------------------------------- +# Cryptographic correctness +# --------------------------------------------------------------------------- + +def test_attestation_signature_verifies(monkeypatch, emitter, attest_receipts_dir, attest_key): + from vaara.attestation.sep2787 import verify_attestation, parse_attestation + p, _ = _make_proxy(monkeypatch, emitter=emitter) + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "write_file", "arguments": {"path": "/tmp/y", "content": "hi"}}, + }) + raw = _attests(attest_receipts_dir)[0] + attestation = parse_attestation(raw) + signing_material = attest_key.read_bytes() + assert verify_attestation(attestation, verifying_material=signing_material) + + +def test_receipt_back_link_valid(monkeypatch, emitter, attest_receipts_dir, attest_key): + from vaara.attestation.sep2787 import parse_attestation + from vaara.attestation.receipt import parse_receipt, verify_back_link + p, _ = _make_proxy(monkeypatch, emitter=emitter) + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "list_dir", "arguments": {"path": "/tmp"}}, + }) + attestation = parse_attestation(_attests(attest_receipts_dir)[0]) + receipt = parse_receipt(_receipts(attest_receipts_dir)[0]) + result = verify_back_link(receipt, attestation=attestation) + assert result.ok + + +def test_receipt_signature_verifies(monkeypatch, emitter, attest_receipts_dir, attest_key): + from vaara.attestation.receipt import parse_receipt, verify_receipt_signature + p, _ = _make_proxy(monkeypatch, emitter=emitter) + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "run_cmd", "arguments": {}}, + }) + receipt = parse_receipt(_receipts(attest_receipts_dir)[0]) + signing_material = attest_key.read_bytes() + assert verify_receipt_signature(receipt, verifying_material=signing_material) + + +def test_successful_outcome_maps_to_executed(monkeypatch, emitter, attest_receipts_dir): + p, _ = _make_proxy(monkeypatch, emitter=emitter) + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "ping", "arguments": {}}, + }) + receipt = _receipts(attest_receipts_dir)[0] + assert receipt["outcomeDerived"]["status"] == "executed" + + +def test_upstream_error_maps_to_errored(monkeypatch, emitter, attest_receipts_dir): + p, _ = _make_proxy(monkeypatch, emitter=emitter) + p._upstream.request.return_value = { + "jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "upstream error"}, + } + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "bad_tool", "arguments": {}}, + }) + receipt = _receipts(attest_receipts_dir)[0] + assert receipt["outcomeDerived"]["status"] == "errored" + + +def test_upstream_raise_still_writes_paired_errored_receipt( + monkeypatch, emitter, attest_receipts_dir +): + # Transport failure: the upstream raises rather than returning an error + # response. The attestation is already written, so a paired errored + # receipt must still be emitted instead of leaving an orphan. + # _handle_tools_call re-raises; the proxy dispatch turns ProxyError into a + # client error response one level up. + from vaara.integrations._mcp_upstream import ProxyError + p, _ = _make_proxy(monkeypatch, emitter=emitter) + p._upstream.request.side_effect = ProxyError("upstream unavailable") + with pytest.raises(ProxyError): + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "flaky_tool", "arguments": {}}, + }) + attest_files = sorted(attest_receipts_dir.glob("*-attest.json")) + receipt_files = sorted(attest_receipts_dir.glob("*-receipt.json")) + assert len(attest_files) == 1 + assert len(receipt_files) == 1 + # Pair shares the counter+nonce prefix, and the outcome is errored. + assert attest_files[0].name[:19] == receipt_files[0].name[:19] + assert _receipts(attest_receipts_dir)[0]["outcomeDerived"]["status"] == "errored" + + +# --------------------------------------------------------------------------- +# Attestation content +# --------------------------------------------------------------------------- + +def test_attestation_iss_is_vaara_mcp_proxy(monkeypatch, emitter, attest_receipts_dir): + p, _ = _make_proxy(monkeypatch, emitter=emitter) + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "get", "arguments": {}}, + }) + attest = _attests(attest_receipts_dir)[0] + assert attest["issuerAsserted"]["iss"] == "vaara-mcp-proxy" + + +def test_attestation_intent_derives_from_tool_name(monkeypatch, emitter, attest_receipts_dir): + p, _ = _make_proxy(monkeypatch, emitter=emitter) + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "fetch_url", "arguments": {}}, + }) + attest = _attests(attest_receipts_dir)[0] + assert attest["plannerDeclared"]["intent"] == "tools/call/fetch_url" + + +def test_intent_override_via_request_intent_contextvar( + monkeypatch, emitter, attest_receipts_dir +): + from vaara.integrations.mcp_proxy import _REQUEST_INTENT + p, _ = _make_proxy(monkeypatch, emitter=emitter) + tok = _REQUEST_INTENT.set("customer-service/lookup") + try: + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "lookup", "arguments": {}}, + }) + finally: + _REQUEST_INTENT.reset(tok) + attest = _attests(attest_receipts_dir)[0] + assert attest["plannerDeclared"]["intent"] == "customer-service/lookup" + + +def test_perimeter_blocked_tool_call_writes_no_pair( + monkeypatch, emitter, attest_receipts_dir +): + # Perimeter denylist blocks before intercept; no attestation should be emitted. + p, _ = _make_proxy(monkeypatch, emitter=emitter, denylist={"delete_db"}) + p._handle_tools_call({ + "jsonrpc": "2.0", "id": 1, + "method": "tools/call", + "params": {"name": "delete_db", "arguments": {}}, + }) + assert len(_attests(attest_receipts_dir)) == 0 + assert len(_receipts(attest_receipts_dir)) == 0 + + +# --------------------------------------------------------------------------- +# Manifest fingerprint +# --------------------------------------------------------------------------- + +def test_manifest_fingerprint_upgrades_on_tools_list( + monkeypatch, emitter, attest_receipts_dir +): + p, _ = _make_proxy(monkeypatch, emitter=emitter) + # Initially cmd-hash. + assert emitter.fingerprint_for("default").startswith("cmd:sha256:") + # Fake a tools/list response coming back through the proxy. + p._upstream.request.return_value = { + "jsonrpc": "2.0", "id": 1, + "result": {"tools": [{"name": "tool_a"}, {"name": "tool_b"}]}, + } + p._handle_tools_list({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + assert emitter.fingerprint_for("default").startswith("manifest:sha256:") + + +def test_manifest_fingerprint_idempotent(monkeypatch, emitter): + p, _ = _make_proxy(monkeypatch, emitter=emitter) + tools_response = { + "jsonrpc": "2.0", "id": 1, + "result": {"tools": [{"name": "t1"}]}, + } + p._upstream.request.return_value = tools_response + p._handle_tools_list({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + first_fp = emitter.fingerprint_for("default") + p._handle_tools_list({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + assert emitter.fingerprint_for("default") == first_fp + + +# --------------------------------------------------------------------------- +# Config error handling +# --------------------------------------------------------------------------- + +def test_build_attest_emitter_rejects_missing_key(tmp_path): + from vaara.integrations._mcp_attest import AttestConfigError, build_attest_emitter + with pytest.raises(AttestConfigError, match="not found"): + build_attest_emitter( + signing_key_path=tmp_path / "nonexistent.key", + receipts_dir=tmp_path / "r", + upstream_commands={"default": ["echo"]}, + ) + + +def test_build_attest_emitter_rejects_short_key(tmp_path): + from vaara.integrations._mcp_attest import AttestConfigError, build_attest_emitter + short = tmp_path / "short.key" + short.write_bytes(b"tooshort") + with pytest.raises(AttestConfigError, match="at least 16 bytes"): + build_attest_emitter( + signing_key_path=short, + receipts_dir=tmp_path / "r", + upstream_commands={"default": ["echo"]}, + )