diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c658b4..7957cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.39.1] - 2026-05-27 + +**Theme: SEP-2787 reference impl follows the spec into camelCase.** +soup-oss adopted MCP camelCase convention for SEP-2787 envelope field +names in `modelcontextprotocol/modelcontextprotocol@48c739b1`. Vaara's +proposed-shape reference implementation now emits camelCase JSON keys +on the serialisation boundary while keeping Python-side attributes in +snake_case. + +### Changed +- `Attestation.to_dict()` and the JCS-canonical signing payload emit + `plannerDeclared`, `issuerAsserted`, `payloadDerived`, `toolCalls`, + `serverFingerprint`, `secretVersion`, `expSeconds`, + `requestedCapability`, `projectionDigest`. The proposed-shape + reference now matches MCP camelCase convention; Python dataclass + attributes stay snake_case so user code is unchanged. +- `docs/sep2787-overt-mapping.md` field-by-field table updated to the + camelCase shape. +- New `issuer_to_dict` helper in `vaara.attestation._sep2787_types` + replaces the prior `asdict()` call so the issuer block sorts and + renames deterministically without leaking Python-internal names. + +### SEP-2787 reference implementation tag +- `sep2787-ref-v1`: camelCase envelope. Pinned for cross-repo + provenance citation against + `modelcontextprotocol/modelcontextprotocol#2787` and the v0 test + vector PR (`#2789`, head `2a9360f`). +- `sep2787-ref-v0` (preserved at commit `3d7af54`): snake_case envelope, + the historical proposed-shape artefact. + ## [0.39.0] - 2026-05-27 **Theme: v9 classifier retrain on a BIPIA-augmented corpus, with one diff --git a/clients/ts/package.json b/clients/ts/package.json index 1c39c81..2e70fa7 100644 --- a/clients/ts/package.json +++ b/clients/ts/package.json @@ -1,6 +1,6 @@ { "name": "@vaara/client", - "version": "0.39.0", + "version": "0.39.1", "description": "TypeScript client for the Vaara HTTP API. Conformal risk scoring, hash-chained audit, policy reload, named detectors.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/docs/sep2787-overt-mapping.md b/docs/sep2787-overt-mapping.md index d3bf06f..9a99755 100644 --- a/docs/sep2787-overt-mapping.md +++ b/docs/sep2787-overt-mapping.md @@ -15,8 +15,8 @@ reason. ## Trust-surface alignment The SEP-2787 proposed shape groups envelope fields under three -trust-surface blocks (`planner_declared`, `issuer_asserted`, -`payload_derived`). OVERT does not group fields explicitly but applies +trust-surface blocks (`plannerDeclared`, `issuerAsserted`, +`payloadDerived`). OVERT does not group fields explicitly but applies the same separation through field semantics. The mapping below names which block each OVERT field corresponds to. @@ -24,17 +24,17 @@ which block each OVERT field corresponds to. | OVERT Base Envelope (CBOR) | SEP-2787 proposed shape (JSON) | Trust surface | Notes | |---|---|---|---| -| `blinded_identifier` (32 bytes) | `issuer_asserted.nonce` (base64url) | issuer-asserted | Both serve replay-protection. OVERT uses 32 bytes per action; SEP-2787 uses 18 bytes base64url-encoded per attestation. | -| `request_commitment` (HMAC-SHA256, bytes) | `payload_derived.args_digest` (`sha256:`) | payload-derived | Direct semantic match. OVERT uses keyed HMAC over a SHA-256 digest of the request content; SEP-2787 ArgsDigest is a plain SHA-256 over JCS-canonical bytes. Both keep the raw payload local. | +| `blinded_identifier` (32 bytes) | `issuerAsserted.nonce` (base64url) | issuer-asserted | Both serve replay-protection. OVERT uses 32 bytes per action; SEP-2787 uses 18 bytes base64url-encoded per attestation. | +| `request_commitment` (HMAC-SHA256, bytes) | `payloadDerived.argsDigest` (`sha256:`) | payload-derived | Direct semantic match. OVERT uses keyed HMAC over a SHA-256 digest of the request content; SEP-2787 ArgsDigest is a plain SHA-256 over JCS-canonical bytes. Both keep the raw payload local. | | `encoder_binary_identity` (SHA-256, bytes) | (no direct equivalent) | n/a | OVERT pins the arbiter implementation + version + policy hash at signing time. SEP-2787 v1 has no equivalent. Vaara's reference implementation could attach an extension field for this. | -| `non_content_metadata` (CBOR map) | `planner_declared.requested_capability` (string) | planner-declared | Partial match. OVERT carries structural classification fields (action class, severity, decision); SEP-2787 has only the capability string in the proposed shape. | +| `non_content_metadata` (CBOR map) | `plannerDeclared.requestedCapability` (string) | planner-declared | Partial match. OVERT carries structural classification fields (action class, severity, decision); SEP-2787 has only the capability string in the proposed shape. | | `monotonic_counter` (uint64) | (no direct equivalent) | n/a | OVERT requires a strictly increasing per-arbiter sequence to detect gaps. SEP-2787 has no monotonic counter; replay protection is per-nonce within TTL. | -| `nanosecond_timestamp` (uint64) | `issuer_asserted.iat` (ISO 8601 string) | issuer-asserted | Same semantics, different encoding. OVERT uses uint64 nanoseconds for cross-language stability; SEP-2787 uses ISO 8601 strings for JSON-native consumption. | -| `key_identifier` (SHA-256 over public key, bytes) | `issuer_asserted.secret_version` (string) | issuer-asserted | OVERT binds the verifying key cryptographically via SHA-256 fingerprint; SEP-2787 names it via opaque version string and leaves key lookup to the verifier. | +| `nanosecond_timestamp` (uint64) | `issuerAsserted.iat` (ISO 8601 string) | issuer-asserted | Same semantics, different encoding. OVERT uses uint64 nanoseconds for cross-language stability; SEP-2787 uses ISO 8601 strings for JSON-native consumption. | +| `key_identifier` (SHA-256 over public key, bytes) | `issuerAsserted.secretVersion` (string) | issuer-asserted | OVERT binds the verifying key cryptographically via SHA-256 fingerprint; SEP-2787 names it via opaque version string and leaves key lookup to the verifier. | | `arbiter_instance_identifier` (16 bytes, UUID) | (no direct equivalent) | n/a | OVERT identifies the specific arbiter instance that produced the envelope. SEP-2787 conflates this with `iss`. | | `signature` (Ed25519, 64 bytes) | `signature` (hex-encoded) | binding output | Different curve choices. OVERT mandates Ed25519. SEP-2787 supports HS256 (HMAC-SHA256), ES256 (ECDSA P-256), RS256 (RSASSA-PKCS1-v1_5). | -| (no equivalent) | `planner_declared.intent` (string) | planner-declared | Human-readable justification required by EU AI Act Article 12 audit reconstruction. OVERT can carry this in `non_content_metadata` but does not require it as a top-level field. | -| (no equivalent) | `planner_declared.tool_calls[*].name` and `.server_fingerprint` | planner-declared | MCP-specific binding. OVERT is transport-agnostic and does not name a tool or server in the envelope. | +| (no equivalent) | `plannerDeclared.intent` (string) | planner-declared | Human-readable justification required by EU AI Act Article 12 audit reconstruction. OVERT can carry this in `non_content_metadata` but does not require it as a top-level field. | +| (no equivalent) | `plannerDeclared.toolCalls[*].name` and `.serverFingerprint` | planner-declared | MCP-specific binding. OVERT is transport-agnostic and does not name a tool or server in the envelope. | ## Canonicalization diff --git a/pyproject.toml b/pyproject.toml index 650ec8d..66861e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vaara" -version = "0.39.0" +version = "0.39.1" 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/src/vaara/__init__.py b/src/vaara/__init__.py index 4e95168..700e023 100644 --- a/src/vaara/__init__.py +++ b/src/vaara/__init__.py @@ -6,7 +6,7 @@ oversight. """ -__version__ = "0.39.0" +__version__ = "0.39.1" from vaara.pipeline import InterceptionPipeline, InterceptionResult diff --git a/src/vaara/attestation/_sep2787_emit.py b/src/vaara/attestation/_sep2787_emit.py index b8b2aa4..a327ecb 100644 --- a/src/vaara/attestation/_sep2787_emit.py +++ b/src/vaara/attestation/_sep2787_emit.py @@ -6,7 +6,6 @@ from __future__ import annotations import time -from dataclasses import asdict from typing import Any, Optional from vaara.attestation._sep2787_canonical import ( @@ -32,6 +31,7 @@ IssuerAsserted, PlannerDeclared, args_to_dict, + issuer_to_dict, planner_to_dict, ) @@ -52,9 +52,9 @@ def _signing_payload( body = { "version": version, "alg": alg, - "planner_declared": planner_to_dict(planner_declared), - "issuer_asserted": asdict(issuer_asserted), - "payload_derived": [args_to_dict(a) for a in payload_derived], + "plannerDeclared": planner_to_dict(planner_declared), + "issuerAsserted": issuer_to_dict(issuer_asserted), + "payloadDerived": [args_to_dict(a) for a in payload_derived], } return canonical_json(body) @@ -85,10 +85,10 @@ def emit_attestation( if alg not in VALID_ALGS: raise AttestationError(f"unsupported alg: {alg!r}") if not planner_declared.intent.strip(): - raise AttestationError("planner_declared.intent MUST be non-empty") + raise AttestationError("plannerDeclared.intent MUST be non-empty") if not planner_declared.tool_calls: raise AttestationError( - "planner_declared.tool_calls MUST contain at least one entry" + "plannerDeclared.toolCalls MUST contain at least one entry" ) issuer_asserted = IssuerAsserted( diff --git a/src/vaara/attestation/_sep2787_types.py b/src/vaara/attestation/_sep2787_types.py index 6d1d939..5323a97 100644 --- a/src/vaara/attestation/_sep2787_types.py +++ b/src/vaara/attestation/_sep2787_types.py @@ -5,7 +5,7 @@ from __future__ import annotations -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field from typing import Any, Literal, Optional, Union Algorithm = Literal["HS256", "ES256", "RS256"] @@ -107,8 +107,8 @@ class Attestation: Composed of three trust-surface blocks plus the signature. The signature is computed over the JCS-canonical encoding of - ``{version, alg, planner_declared, issuer_asserted, - payload_derived}`` and does not cover itself. + ``{version, alg, plannerDeclared, issuerAsserted, + payloadDerived}`` and does not cover itself. The ``payload_derived`` tuple parallels ``planner_declared.tool_calls`` in order: index N here is the args @@ -126,9 +126,9 @@ def to_dict(self) -> dict[str, Any]: return { "version": self.version, "alg": self.alg, - "planner_declared": planner_to_dict(self.planner_declared), - "issuer_asserted": asdict(self.issuer_asserted), - "payload_derived": [args_to_dict(a) for a in self.payload_derived], + "plannerDeclared": planner_to_dict(self.planner_declared), + "issuerAsserted": issuer_to_dict(self.issuer_asserted), + "payloadDerived": [args_to_dict(a) for a in self.payload_derived], "signature": self.signature, } @@ -151,7 +151,7 @@ def args_to_dict(args: ArgsCommitment) -> dict[str, Any]: return { "kind": "projection", "projection": args.projection, - "projection_digest": args.projection_digest, + "projectionDigest": args.projection_digest, } raise AttestationError(f"unknown args commitment kind: {type(args)!r}") @@ -159,15 +159,27 @@ def args_to_dict(args: ArgsCommitment) -> dict[str, Any]: def planner_to_dict(planner: PlannerDeclared) -> dict[str, Any]: out: dict[str, Any] = { "intent": planner.intent, - "tool_calls": [ + "toolCalls": [ { "name": tc.name, - "server_fingerprint": tc.server_fingerprint, + "serverFingerprint": tc.server_fingerprint, "args": args_to_dict(tc.args), } for tc in planner.tool_calls ], } if planner.requested_capability is not None: - out["requested_capability"] = planner.requested_capability + out["requestedCapability"] = planner.requested_capability return out + + +def issuer_to_dict(issuer: IssuerAsserted) -> dict[str, Any]: + return { + "alg": issuer.alg, + "expSeconds": issuer.exp_seconds, + "iat": issuer.iat, + "iss": issuer.iss, + "nonce": issuer.nonce, + "secretVersion": issuer.secret_version, + "sub": issuer.sub, + } diff --git a/src/vaara/attestation/_sep2787_verifier.py b/src/vaara/attestation/_sep2787_verifier.py index 8baf456..c529087 100644 --- a/src/vaara/attestation/_sep2787_verifier.py +++ b/src/vaara/attestation/_sep2787_verifier.py @@ -4,10 +4,10 @@ Implements step 5 of the verification rules in the SEP-2787 draft: - If the toolCalls entry uses args_ref, resolve the URI, compute + If the toolCalls entry uses argsRef, resolve the URI, compute SHA-256 over the fetched content, and compare against the stored digest. Confirm the resolved content corresponds to the arguments - being executed. If the entry uses args_projection, compare it + being executed. If the entry uses argsProjection, compare it against the canonicalized runtime arguments (RFC 8785). Identity projections MUST match exactly; redacted projections are verified only to be signed -- the verifier makes no claim about @@ -16,7 +16,7 @@ reject with args_commitment_mismatch. Vaara's three-way args shape (ArgsDigest / ArgsRef / ArgsProjection) -extends the spec's two-way (args_ref / args_projection) with a +extends the spec's two-way (argsRef / argsProjection) with a commitment-only ArgsDigest where the payload never crosses the verifier. For ArgsDigest the verifier recomputes the JCS-canonical hash of the runtime arguments and compares against the bound digest. diff --git a/src/vaara/attestation/sep2787.py b/src/vaara/attestation/sep2787.py index 33b8545..6dee256 100644 --- a/src/vaara/attestation/sep2787.py +++ b/src/vaara/attestation/sep2787.py @@ -6,11 +6,11 @@ written: 1. **Fact-source labels.** Envelope fields are grouped under three - named blocks by trust surface: ``planner_declared`` (intent, tool + named blocks by trust surface: ``plannerDeclared`` (intent, tool name and server bindings the agent planner claims), - ``issuer_asserted`` (iss, sub, iat, exp, nonce, secret_version, + ``issuerAsserted`` (iss, sub, iat, expSeconds, nonce, secretVersion, alg, set by the attestation issuer at signing time), and - ``payload_derived`` (args_digest / args_ref / args_projection, + ``payloadDerived`` (argsDigest / argsRef / argsProjection, deterministically derived from the request payload). The signature is the binding output and lives at the envelope root. 2. **Three-way args shape.** The v1 draft overloads a single @@ -30,7 +30,7 @@ Signing modes follow the v1 draft: HS256 (HMAC-SHA256), ES256 (ECDSA P-256 raw r||s, not DER), RS256 (RSASSA-PKCS1-v1_5). The signature is computed over the JCS-canonical encoding of the four envelope blocks -``{version, alg, planner_declared, issuer_asserted, payload_derived}`` +``{version, alg, plannerDeclared, issuerAsserted, payloadDerived}`` and is excluded from its own input. Install: ``pip install 'vaara[attestation]'``. Requires ``rfc8785`` for diff --git a/tests/test_attestation_sep2787.py b/tests/test_attestation_sep2787.py index c435483..05d97bf 100644 --- a/tests/test_attestation_sep2787.py +++ b/tests/test_attestation_sep2787.py @@ -204,11 +204,15 @@ def test_to_dict_round_trip_shape(): env = _emit_attestation() d = env.to_dict() assert set(d) == { - "version", "alg", "planner_declared", - "issuer_asserted", "payload_derived", "signature", + "version", "alg", "plannerDeclared", + "issuerAsserted", "payloadDerived", "signature", } - assert d["planner_declared"]["tool_calls"][0]["args"]["kind"] == "digest" - assert d["payload_derived"][0]["kind"] == "digest" + assert d["plannerDeclared"]["toolCalls"][0]["args"]["kind"] == "digest" + assert d["plannerDeclared"]["toolCalls"][0]["serverFingerprint"].startswith("sha256:") + assert d["plannerDeclared"]["requestedCapability"] == "filesystem.delete" + assert d["issuerAsserted"]["expSeconds"] == 300 + assert d["issuerAsserted"]["secretVersion"] == "v1" + assert d["payloadDerived"][0]["kind"] == "digest" def test_cross_alg_verification_fails():