From 01ea7869e615a3260e6383ee46b577f0005654c6 Mon Sep 17 00:00:00 2001 From: vaaraio <267591518+vaaraio@users.noreply.github.com> Date: Sun, 31 May 2026 11:44:27 +0300 Subject: [PATCH 1/7] fix(registry): trim MCP manifest descriptions to the 100-char registry cap The v0.48.0 release published PyPI, npm, and the GitHub Release, but the MCP registry job rejected both manifests with 'expected length <= 100' on body.description (the EU-reframe descriptions ran ~150 chars). Trim both to keep the EU AI Act lead within the cap. No code change; version stays 0.48.0 and the registry is published from these manifests via mcp-publisher. --- server-vaara-server.json | 2 +- server.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server-vaara-server.json b/server-vaara-server.json index 2369fca..9928378 100644 --- a/server-vaara-server.json +++ b/server-vaara-server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.vaaraio/vaara-server", "title": "Vaara MCP Server", - "description": "EU AI Act runtime evidence as a standalone MCP server. Policy gating, intercept, hash-chained tamper-evident audit with external time anchoring.", + "description": "EU AI Act runtime evidence as a standalone MCP server: gating, tamper-evident audit", "websiteUrl": "https://vaara.io", "repository": { "url": "https://github.com/vaaraio/vaara", diff --git a/server.json b/server.json index 90cb747..a4118cf 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.vaaraio/vaara", "title": "Vaara", - "description": "EU AI Act runtime evidence proxy for MCP servers. Policy-gated tool calls, hash-chained tamper-evident audit with external time anchoring, per-tenant isolation.", + "description": "EU AI Act runtime evidence proxy for MCP servers: gating, tamper-evident audit, time anchor", "websiteUrl": "https://vaara.io", "repository": { "url": "https://github.com/vaaraio/vaara", From 2f0fd796b8b743213e40493bc5eb4a71d45256d1 Mon Sep 17 00:00:00 2001 From: vaaraio <267591518+vaaraio@users.noreply.github.com> Date: Sun, 31 May 2026 12:35:49 +0300 Subject: [PATCH 2/7] feat(attestation): signed decision-record envelope for the server-execution-record SEP Lands the pre-execution decision record as a signed envelope, closing the one unbacked block in docs/sep/sep-server-execution-record.md. Every normative claim in the SEP now has shipped, tested code behind it. - vaara.attestation.decision: DecisionRecord / DecisionDerived, emit, verify-signature, back-link verify, and records_paired (the decision-and-outcome join). Reuses the receipt back-link, issuer-block layout, and JCS + HS256/ES256/RS256 signing stack unchanged; only the decisionDerived block is new. - decision_derived_from_commit bridges the shipped hash-chained CommitPayload onto the wire shape: deny normalizes to block, the float risk basis becomes decimal strings, the epoch decision time becomes ISO 8601 UTC. Lazy import keeps the core audit layer free of the optional attestation extra. - 18 tests: HS/ES/RS round-trips, tamper and wrong-key rejection, back-link binding, instance-scoped pairing with the execution receipt, the float-on-the-wire ban, and the commit bridge. SEP updated: reference-implementation modules listed, the "implementation gap" paragraph replaced with the bridge description. 1114 passed / 12 skipped, ruff + mypy clean. --- docs/sep/sep-server-execution-record.md | 29 +- src/vaara/attestation/_decision_emit.py | 177 +++++++++++++ src/vaara/attestation/_decision_types.py | 169 ++++++++++++ src/vaara/attestation/_decision_verifier.py | 72 +++++ src/vaara/attestation/decision.py | 72 +++++ src/vaara/audit/receipts.py | 88 ++++++- tests/test_decision_record.py | 278 ++++++++++++++++++++ 7 files changed, 874 insertions(+), 11 deletions(-) create mode 100644 src/vaara/attestation/_decision_emit.py create mode 100644 src/vaara/attestation/_decision_types.py create mode 100644 src/vaara/attestation/_decision_verifier.py create mode 100644 src/vaara/attestation/decision.py create mode 100644 tests/test_decision_record.py diff --git a/docs/sep/sep-server-execution-record.md b/docs/sep/sep-server-execution-record.md index a7028a0..af7e460 100644 --- a/docs/sep/sep-server-execution-record.md +++ b/docs/sep/sep-server-execution-record.md @@ -531,6 +531,12 @@ The wire schema in this SEP is the shape shipping in the Vaara MCP proxy - `vaara/attestation/_receipt_verifier.py`: `make_back_link` / `verify_back_link` and `attestation_digest` (sha256 over the JCS-canonical full SEP-2787 wire bytes, signature included): the instance-binding join. +- `vaara/attestation/decision.py`: the decision record of this SEP. + `DecisionRecord` / `DecisionDerived`, `emit_decision_record`, + `verify_decision_signature`, `verify_decision_back_link`, and + `records_paired` (the decision-and-outcome join). Reuses the receipt's + back-link, the issuer-block layout, and the shared signing stack unchanged, + so the decision record adds the `decisionDerived` block and no new crypto. - `vaara/attestation/_sep2787_types.py` and `_sep2787_canonical.py`: the shared commitment shapes (`ArgsRef`, `ArgsProjection`, `make_args_digest`), the issuer-block layout, RFC 8785 canonicalization with float rejection, and the @@ -544,16 +550,19 @@ The wire schema in this SEP is the shape shipping in the Vaara MCP proxy tenant. The external time anchor over the chain head (Security Implications) is the next shipped step (v0.48). -**Implementation gap (decision record).** The pre-execution **decision** is today -emitted as an audit event and as a SHA-256 commit-outcome receipt pair -(`vaara/audit/receipts.py`: `CommitPayload` with `decision`, `risk_score`, -`threshold_allow`, `threshold_deny`, `decided_at`, and `OutcomePayload`), but as -a hash-chained pair rather than as a signed envelope in the SEP-2787 issuer-block -shape. The `decisionDerived` block in this SEP standardizes that content as a -signed envelope and is the part of the reference implementation that lands -alongside this SEP. The field mapping is direct: `decision` to `decision`, -`risk_score` to `riskScore`, `threshold_allow` to `thresholdAllow`, -`threshold_deny` to `thresholdBlock`, `decided_at` to `decidedAt`. +**Bridge from the shipped audit decision.** The audit trail already records the +pre-execution decision as a hash-chained `CommitPayload` +(`vaara/audit/receipts.py`: `decision`, `risk_score`, `threshold_allow`, +`threshold_deny`, `decided_at`). `decision_derived_from_commit` maps that +payload onto the signed `decisionDerived` wire shape. The mapping is mechanical +but not a rename: the verdict vocabulary is normalized (the audit layer records +`deny`, the wire enum uses `block`; the review family maps to `escalate`), the +float risk basis becomes decimal strings (floats are banned on the wire), and +the epoch decision time becomes an ISO 8601 UTC string. `policy_id`, +`clientTurnId`, and `reason` are not carried on the commit payload and are +supplied by the caller when available. This keeps the long-retained signed +record free of the float canonicalization drift that the hash-chained payload +tolerated internally. ## Test Vectors diff --git a/src/vaara/attestation/_decision_emit.py b/src/vaara/attestation/_decision_emit.py new file mode 100644 index 0000000..b64727a --- /dev/null +++ b/src/vaara/attestation/_decision_emit.py @@ -0,0 +1,177 @@ +"""Emit and verify-signature for decision-record envelopes. + +Internal module. Public surface is in ``vaara.attestation.decision``. + +Reuses the SEP-2787 canonicalization (RFC 8785 JCS) and signing stack +(HS256 / ES256 / RS256) unchanged. The only new wire shape is the +envelope layout; the cryptographic primitives are shared so a verifier +that already handles SEP-2787 signatures handles decision records with +no new crypto code. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from vaara.attestation._decision_types import ( + DecisionDerived, + DecisionRecord, + IssuerAsserted, + decision_to_dict, +) +from vaara.attestation._receipt_types import BackLink, back_link_to_dict, receipt_asserted_to_dict +from vaara.attestation._sep2787_canonical import ( + canonical_json, + new_nonce, + now_iso8601, +) +from vaara.attestation._sep2787_signing import ( + sign_es256, + sign_hs256, + sign_rs256, + verify_es256, + verify_hs256, + verify_rs256, +) +from vaara.attestation._sep2787_types import ( + VALID_ALGS, + Algorithm, + AttestationError, +) + + +def _signing_payload( + *, + version: int, + alg: Algorithm, + back_link: BackLink, + decision_derived: DecisionDerived, + issuer_asserted: IssuerAsserted, +) -> bytes: + """JCS-canonical encoding of the decision blocks, signature excluded.""" + body = { + "version": version, + "alg": alg, + "backLink": back_link_to_dict(back_link), + "decisionDerived": decision_to_dict(decision_derived), + "issuerAsserted": receipt_asserted_to_dict(issuer_asserted), + } + return canonical_json(body) + + +def emit_decision_record( + *, + back_link: BackLink, + decision_derived: DecisionDerived, + iss: str, + sub: str, + secret_version: str, + alg: Algorithm, + signing_material: Any, + nonce: Optional[str] = None, + iat: Optional[str] = None, + version: int = 1, +) -> DecisionRecord: + """Build, JCS-canonicalize, and sign a DecisionRecord envelope. + + ``back_link`` joins the decision to the SEP-2787 attestation it + governs (build it with ``make_back_link``). ``decision_derived`` + carries the verdict, its risk basis, and the decision time. Any + float in the risk basis is rejected at the JCS boundary; the risk + fields MUST be decimal strings. + + ``signing_material`` is either a bytes shared secret (HS256) or a + private-key object from ``cryptography.hazmat`` (ES256 / RS256). + """ + if alg not in VALID_ALGS: + raise AttestationError(f"unsupported alg: {alg!r}") + if not back_link.attestation_digest.startswith("sha256:"): + raise AttestationError( + "backLink.attestationDigest MUST be a 'sha256:' digest" + ) + if not back_link.attestation_nonce: + raise AttestationError("backLink.attestationNonce MUST be non-empty") + + issuer_asserted = IssuerAsserted( + iss=iss, + sub=sub, + iat=iat or now_iso8601(), + nonce=nonce or new_nonce(), + secret_version=secret_version, + alg=alg, + ) + + payload = _signing_payload( + version=version, + alg=alg, + back_link=back_link, + decision_derived=decision_derived, + issuer_asserted=issuer_asserted, + ) + + if alg == "HS256": + if not isinstance(signing_material, (bytes, bytearray)): + raise AttestationError("HS256 requires bytes shared_secret") + signature_hex = sign_hs256(payload, shared_secret=bytes(signing_material)) + elif alg == "ES256": + signature_hex = sign_es256(payload, private_key=signing_material) + elif alg == "RS256": + signature_hex = sign_rs256(payload, private_key=signing_material) + else: + raise AttestationError(f"unreachable alg: {alg!r}") + + return DecisionRecord( + version=version, + alg=alg, + back_link=back_link, + decision_derived=decision_derived, + issuer_asserted=issuer_asserted, + signature=signature_hex, + ) + + +def verify_decision_signature( + record: DecisionRecord, + *, + verifying_material: Any, +) -> bool: + """Verify the decision-record signature only. + + Returns True iff the signature matches the JCS-canonical encoding of + the record blocks under ``verifying_material``. Back-link and pairing + checks are composed separately via ``verify_decision_back_link`` and + ``records_paired``; a decision record is a durable record so there is + no TTL to enforce. + + ``verifying_material`` is either a bytes shared secret (HS256) or a + public-key object from ``cryptography.hazmat`` (ES256 / RS256). + """ + payload = _signing_payload( + version=record.version, + alg=record.alg, + back_link=record.back_link, + decision_derived=record.decision_derived, + issuer_asserted=record.issuer_asserted, + ) + + if record.alg == "HS256": + if not isinstance(verifying_material, (bytes, bytearray)): + return False + return verify_hs256( + payload, + signature_hex=record.signature, + shared_secret=bytes(verifying_material), + ) + if record.alg == "ES256": + return verify_es256( + payload, + signature_hex=record.signature, + public_key=verifying_material, + ) + if record.alg == "RS256": + return verify_rs256( + payload, + signature_hex=record.signature, + public_key=verifying_material, + ) + return False diff --git a/src/vaara/attestation/_decision_types.py b/src/vaara/attestation/_decision_types.py new file mode 100644 index 0000000..d579032 --- /dev/null +++ b/src/vaara/attestation/_decision_types.py @@ -0,0 +1,169 @@ +"""Dataclasses and serialization for decision-record envelopes. + +Internal module. Public surface is in ``vaara.attestation.decision``. + +The decision record is the pre-execution sibling of the SEP-2787 request +attestation and the post-execution sibling is the execution receipt. The +attestation binds an *observed* ``tools/call`` request before execution. +The receipt binds the *outcome* after execution. The decision record +covers the half between them: the governing server's policy verdict and +its risk basis, signed and committed *before* the side effect runs, so a +verifier can prove the verdict was fixed before the action. + +Three blocks plus the signature, mirroring the SEP-2787 and receipt +trust-surface layout so all three envelopes verify with the same +canonicalization and signing stack: + +1. ``backLink`` joins the decision to the SEP-2787 attestation it + governs. Same shape as the receipt back-link: the attestation's + nonce plus a digest over the full attestation wire bytes (signature + included), which pins the exact attestation instance. +2. ``issuerAsserted`` is the governing server's issuer block. It carries + the same fields as the receipt's ``receiptAsserted`` block; the wire + key differs because the decision and the outcome are distinct records. +3. ``decisionDerived`` carries the verdict (``allow`` / ``block`` / + ``escalate``), the risk basis that drove it, and the decision time. + +A decision record is a durable record, not a time-bounded capability, so +it carries no ``exp`` and the verifier enforces no TTL. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, Optional + +from vaara.attestation._receipt_types import ( + BackLink, + ReceiptAsserted, + back_link_from_dict, + back_link_to_dict, + receipt_asserted_from_dict, + receipt_asserted_to_dict, +) +from vaara.attestation._sep2787_types import VALID_ALGS, Algorithm, AttestationError + +# The governing server's issuer block is identical in shape to the +# receipt's ``receiptAsserted`` (iss, sub, iat, nonce, secretVersion, +# alg). Reuse the dataclass; only the wire key differs (``issuerAsserted``). +IssuerAsserted = ReceiptAsserted + +DecisionVerdict = Literal["allow", "block", "escalate"] +VALID_VERDICTS: frozenset[str] = frozenset({"allow", "block", "escalate"}) + + +@dataclass(frozen=True) +class DecisionDerived: + """The governing server's verdict and the basis for it. + + ``decision`` is one of ``allow`` / ``block`` / ``escalate``. + ``decided_at`` is the ISO 8601 UTC decision time. The risk-basis + fields are optional and, when present, are decimal strings: floats + are banned on the wire (the JCS boundary rejects them) because + cross-stack float behaviour is the most common source of signature + drift. ``client_turn_id``, when present, records that the client + *claimed* a turn id (SEP-2817 correlation), not that the server + vouches for it. + """ + + decision: DecisionVerdict + decided_at: str + reason: Optional[str] = None + risk_score: Optional[str] = None + threshold_allow: Optional[str] = None + threshold_block: Optional[str] = None + policy_id: Optional[str] = None + client_turn_id: Optional[str] = None + + +@dataclass(frozen=True) +class DecisionRecord: + """Decision-record envelope. + + ``backLink`` plus two trust-surface blocks plus the signature. The + signature is computed over the JCS-canonical encoding of + ``{version, alg, backLink, decisionDerived, issuerAsserted}`` and + does not cover itself. + """ + + version: int + alg: Algorithm + back_link: BackLink + decision_derived: DecisionDerived + issuer_asserted: IssuerAsserted + signature: str + + def to_dict(self) -> dict[str, Any]: + return { + "version": self.version, + "alg": self.alg, + "backLink": back_link_to_dict(self.back_link), + "decisionDerived": decision_to_dict(self.decision_derived), + "issuerAsserted": receipt_asserted_to_dict(self.issuer_asserted), + "signature": self.signature, + } + + +def decision_to_dict(dd: DecisionDerived) -> dict[str, Any]: + out: dict[str, Any] = { + "decision": dd.decision, + "decidedAt": dd.decided_at, + } + if dd.reason is not None: + out["reason"] = dd.reason + if dd.risk_score is not None: + out["riskScore"] = dd.risk_score + if dd.threshold_allow is not None: + out["thresholdAllow"] = dd.threshold_allow + if dd.threshold_block is not None: + out["thresholdBlock"] = dd.threshold_block + if dd.policy_id is not None: + out["policyId"] = dd.policy_id + if dd.client_turn_id is not None: + out["clientTurnId"] = dd.client_turn_id + return out + + +def decision_from_dict(d: dict[str, Any]) -> DecisionDerived: + for required in ("decision", "decidedAt"): + if required not in d: + raise AttestationError( + f"decisionDerived missing required field {required!r}" + ) + if d["decision"] not in VALID_VERDICTS: + raise AttestationError(f"invalid decision verdict {d['decision']!r}") + return DecisionDerived( + decision=d["decision"], + decided_at=d["decidedAt"], + reason=d.get("reason"), + risk_score=d.get("riskScore"), + threshold_allow=d.get("thresholdAllow"), + threshold_block=d.get("thresholdBlock"), + policy_id=d.get("policyId"), + client_turn_id=d.get("clientTurnId"), + ) + + +def decision_record_from_dict(d: dict[str, Any]) -> DecisionRecord: + """Reconstruct a DecisionRecord from its wire JSON dict. + + Inverse of ``DecisionRecord.to_dict()``. Field-presence validation + only; signature verification still requires the caller's keying + material. + """ + for required in ( + "version", "alg", "backLink", "decisionDerived", + "issuerAsserted", "signature", + ): + if required not in d: + raise AttestationError(f"decision record missing required field {required!r}") + if d["alg"] not in VALID_ALGS: + raise AttestationError(f"unsupported alg {d['alg']!r}") + return DecisionRecord( + version=d["version"], + alg=d["alg"], + back_link=back_link_from_dict(d["backLink"]), + decision_derived=decision_from_dict(d["decisionDerived"]), + issuer_asserted=receipt_asserted_from_dict(d["issuerAsserted"]), + signature=d["signature"], + ) diff --git a/src/vaara/attestation/_decision_verifier.py b/src/vaara/attestation/_decision_verifier.py new file mode 100644 index 0000000..3b5e44c --- /dev/null +++ b/src/vaara/attestation/_decision_verifier.py @@ -0,0 +1,72 @@ +"""Back-link verification and pairing for decision records. + +Internal module. Public surface is in ``vaara.attestation.decision``. + +The back-link is the join that makes a SEP-2787 attestation and a +decision record one verifiable pair, exactly as it does for an execution +receipt. Pairing then joins a decision record and the execution receipt +that answer the same governed call: both carry the same back-link, so a +verifier holding all three can reconstruct what was permitted, why, and +what the call did. + +Result-commitment and signature checks are not duplicated here. The +attestation-digest computation (``attestation_digest``) and the +``BackLinkResult`` type are shared with the receipt verifier. +""" + +from __future__ import annotations + +import hmac + +from vaara.attestation._decision_types import DecisionRecord +from vaara.attestation._receipt_types import ExecutionReceipt +from vaara.attestation._receipt_verifier import ( + BACK_LINK_MISMATCH, + BackLinkResult, + attestation_digest, +) +from vaara.attestation._sep2787_types import Attestation + + +def verify_decision_back_link( + record: DecisionRecord, + *, + attestation: Attestation, +) -> BackLinkResult: + """Confirm the decision record's back-link pins ``attestation``. + + Recomputes the attestation digest and compares both it and the nonce + against the record's ``backLink``. The digest is the binding check; + the nonce is a fast-correlation field that must also agree so a + record cannot carry one attestation's digest under another's nonce. + """ + expected_digest = attestation_digest(attestation) + if not hmac.compare_digest( + record.back_link.attestation_digest, expected_digest + ): + return BackLinkResult(ok=False, reason=BACK_LINK_MISMATCH) + if record.back_link.attestation_nonce != attestation.issuer_asserted.nonce: + return BackLinkResult(ok=False, reason=BACK_LINK_MISMATCH) + return BackLinkResult(ok=True) + + +def records_paired( + decision: DecisionRecord, + receipt: ExecutionReceipt, +) -> bool: + """True iff a decision record and an execution receipt describe one call. + + They pair when both carry the same back-link: the attestation digest + (constant-time compared) and the attestation nonce both agree. This + is instance-binding, not content-binding, so two byte-identical calls + produce distinct attestations and therefore do not cross-pair. + """ + if not hmac.compare_digest( + decision.back_link.attestation_digest, + receipt.back_link.attestation_digest, + ): + return False + return ( + decision.back_link.attestation_nonce + == receipt.back_link.attestation_nonce + ) diff --git a/src/vaara/attestation/decision.py b/src/vaara/attestation/decision.py new file mode 100644 index 0000000..b8f2cc4 --- /dev/null +++ b/src/vaara/attestation/decision.py @@ -0,0 +1,72 @@ +"""Decision records: the pre-execution sibling of SEP-2787. + +SEP-2787 attests a ``tools/call`` *request* before it runs. The +execution receipt (``vaara.attestation.receipt``) binds the *outcome* +after it runs. The decision record covers the half between them: the +governing server's policy verdict and its risk basis, signed and +committed *before* the side effect. This is the commit-before-execute +property that lets a verifier prove the verdict was fixed before the +action ran. + +A decision record carries three parts plus a signature: + +- ``backLink`` pins the SEP-2787 attestation by nonce and by a digest + over its full wire bytes, the same instance-binding the receipt uses. +- ``issuerAsserted`` is the governing server's issuer block. +- ``decisionDerived`` carries the verdict (``allow`` / ``block`` / + ``escalate``), the risk basis (decimal-string risk score and + thresholds, an optional policy id), and the decision time. + +A decision record verifies in two composable checks: the signature +(``verify_decision_signature``) and the back-link to its attestation +(``verify_decision_back_link``). ``records_paired`` then joins it to the +execution receipt that answers the same call. A decision record is a +durable record rather than a time-bounded capability, so there is no TTL. + +Canonicalization (RFC 8785 JCS) and signing (HS256 / ES256 / RS256) are +shared with ``vaara.attestation.sep2787`` and the receipt module +unchanged. A verifier that already checks SEP-2787 signatures needs no +new crypto to check decision records. + +Install: ``pip install 'vaara[attestation]'``. +""" + +from __future__ import annotations + +from vaara.attestation._decision_emit import ( + emit_decision_record, + verify_decision_signature, +) +from vaara.attestation._decision_types import ( + DecisionDerived, + DecisionRecord, + DecisionVerdict, + IssuerAsserted, + decision_record_from_dict as parse_decision_record, +) +from vaara.attestation._decision_verifier import ( + records_paired, + verify_decision_back_link, +) +from vaara.attestation._receipt_types import BackLink +from vaara.attestation._receipt_verifier import ( + BackLinkResult, + attestation_digest, + make_back_link, +) + +__all__ = [ + "BackLink", + "BackLinkResult", + "DecisionDerived", + "DecisionRecord", + "DecisionVerdict", + "IssuerAsserted", + "attestation_digest", + "emit_decision_record", + "make_back_link", + "parse_decision_record", + "records_paired", + "verify_decision_back_link", + "verify_decision_signature", +] diff --git a/src/vaara/audit/receipts.py b/src/vaara/audit/receipts.py index 0029f61..56ae4da 100644 --- a/src/vaara/audit/receipts.py +++ b/src/vaara/audit/receipts.py @@ -22,10 +22,14 @@ import json import math from dataclasses import asdict, dataclass, field -from typing import Any, Optional +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Optional from vaara.audit.trail import AuditRecord, AuditTrail, EventType +if TYPE_CHECKING: + from vaara.attestation._decision_types import DecisionDerived + @dataclass(frozen=True) class CommitPayload: @@ -215,6 +219,88 @@ def _event_to_decision(event_type: EventType) -> str: return "deny" if event_type == EventType.ACTION_BLOCKED else "allow" +# The audit layer records a verdict as ``allow`` / ``deny``; a held-for-review +# action is recorded as ``escalate`` / ``review`` / ``refer`` depending on the +# policy vocabulary. The SEP-2787 decision-record wire enum is +# ``allow`` / ``block`` / ``escalate``, so ``deny`` normalises to ``block`` and +# the review family normalises to ``escalate``. +_VERDICT_TO_WIRE: dict[str, str] = { + "allow": "allow", + "deny": "block", + "block": "block", + "escalate": "escalate", + "review": "escalate", + "refer": "escalate", +} + + +def _verdict_to_wire(decision: str) -> str: + wire = _VERDICT_TO_WIRE.get(decision.strip().lower()) + if wire is None: + raise ValueError(f"unmappable audit decision {decision!r}") + return wire + + +def _decimal_str(value: float) -> str: + """Stable decimal string for a risk score or threshold. + + Floats are banned on the decision-record wire (the JCS boundary + rejects them) because cross-stack float behaviour is the most common + source of signature drift. ``repr`` gives the shortest round-tripping + decimal; scientific notation is expanded so the wire value is always + a plain decimal. + """ + if not math.isfinite(value): + raise ValueError("risk score and thresholds MUST be finite") + s = repr(float(value)) + if "e" in s or "E" in s: + s = f"{value:.12f}".rstrip("0").rstrip(".") + return s + + +def _epoch_to_iso8601(epoch: float) -> str: + """Epoch seconds to an RFC 3339 / ISO 8601 UTC string with a ``Z`` suffix.""" + return ( + datetime.fromtimestamp(epoch, tz=timezone.utc) + .isoformat(timespec="seconds") + .replace("+00:00", "Z") + ) + + +def decision_derived_from_commit( + commit: "CommitPayload", + *, + policy_id: Optional[str] = None, + client_turn_id: Optional[str] = None, + reason: Optional[str] = None, +) -> "DecisionDerived": + """Bridge a shipped ``CommitPayload`` to a SEP-2787 ``DecisionDerived``. + + The commit payload is the hash-chained pre-action decision the audit + trail already records. This maps it onto the signed decision-record + wire shape: the verdict vocabulary is normalised (``deny`` to + ``block``), the float risk basis becomes decimal strings, and the + epoch decision time becomes an ISO 8601 UTC string. ``policy_id``, + ``client_turn_id``, and ``reason`` are not carried on the commit + payload, so the caller supplies them when available. + + Imports ``DecisionDerived`` lazily so the core audit layer does not + hard-depend on the optional ``attestation`` extra. + """ + from vaara.attestation._decision_types import DecisionDerived + + return DecisionDerived( + decision=_verdict_to_wire(commit.decision), # type: ignore[arg-type] + decided_at=_epoch_to_iso8601(commit.decided_at), + reason=reason, + risk_score=_decimal_str(commit.risk_score), + threshold_allow=_decimal_str(commit.threshold_allow), + threshold_block=_decimal_str(commit.threshold_deny), + policy_id=policy_id, + client_turn_id=client_turn_id, + ) + + def _coerce_float(value: Any) -> Optional[float]: if value is None: return None diff --git a/tests/test_decision_record.py b/tests/test_decision_record.py new file mode 100644 index 0000000..6dad7f7 --- /dev/null +++ b/tests/test_decision_record.py @@ -0,0 +1,278 @@ +"""Decision-record round-trip, back-link, pairing, and commit-bridge tests. + +The decision record is the pre-execution sibling of the SEP-2787 request +attestation and the execution receipt: it binds the governing server's +policy verdict (``allow`` / ``block`` / ``escalate``) and its risk basis +before the side effect runs, and links back to the attestation it governs. +""" + +from __future__ import annotations + +import dataclasses +import importlib.util + +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.asymmetric import ec, rsa # noqa: E402 + +from vaara.attestation.decision import ( # noqa: E402 + DecisionDerived, + emit_decision_record, + make_back_link, + parse_decision_record, + records_paired, + verify_decision_back_link, + verify_decision_signature, +) +from vaara.attestation.receipt import ( # noqa: E402 + OutcomeDerived, + emit_receipt, + make_result_digest, +) +from vaara.attestation.sep2787 import ( # noqa: E402 + PayloadDerived, + PlannerDeclared, + ToolCallBinding, + emit_attestation, + make_args_digest, +) + +HS_SECRET = b"\x42" * 32 + + +def _attestation(): + payload = PayloadDerived(tool_calls=(ToolCallBinding( + name="delete_file", + server_fingerprint="sha256:" + "1" * 64, + args=make_args_digest({"path": "/archive/2024-Q3.md"}), + ),)) + return emit_attestation( + planner_declared=PlannerDeclared(intent="archive obsolete report"), + payload_derived=payload, + iss="issuer://test", + sub="agent:archiver", + secret_version="v1", + alg="HS256", + signing_material=HS_SECRET, + ) + + +def _decision(decision="allow"): + return DecisionDerived( + decision=decision, + decided_at="2026-05-31T09:30:00Z", + reason="risk below allow threshold", + risk_score="0.21", + threshold_allow="0.40", + threshold_block="0.70", + policy_id="sha256:3c9d4b8a", + ) + + +def _emit(att, **overrides): + kwargs = dict( + back_link=make_back_link(att), + decision_derived=_decision(), + iss="vaara-proxy://acme-eu", + sub="tenant:acme/agent:billing-bot", + secret_version="2026-05", + alg="HS256", + signing_material=HS_SECRET, + ) + kwargs.update(overrides) + return emit_decision_record(**kwargs) + + +def test_hs256_round_trip(): + r = _emit(_attestation()) + assert r.alg == "HS256" + assert r.version == 1 + assert r.signature + assert verify_decision_signature(r, verifying_material=HS_SECRET) is True + + +def test_es256_round_trip(): + priv = ec.generate_private_key(ec.SECP256R1()) + r = _emit(_attestation(), alg="ES256", signing_material=priv) + assert len(r.signature) == 128 + assert verify_decision_signature( + r, verifying_material=priv.public_key() + ) is True + + +def test_rs256_round_trip(): + priv = rsa.generate_private_key(public_exponent=65537, key_size=2048) + r = _emit(_attestation(), alg="RS256", signing_material=priv) + assert verify_decision_signature( + r, verifying_material=priv.public_key() + ) is True + + +def test_wrong_secret_fails_signature(): + r = _emit(_attestation()) + assert verify_decision_signature(r, verifying_material=b"\x00" * 32) is False + + +def test_tampered_decision_fails_signature(): + att = _attestation() + r = _emit(att) + tampered = dataclasses.replace(r, decision_derived=_decision("block")) + assert verify_decision_signature(tampered, verifying_material=HS_SECRET) is False + + +def test_escalate_round_trips_and_verifies(): + att = _attestation() + r = _emit(att, decision_derived=_decision("escalate")) + assert r.decision_derived.decision == "escalate" + assert verify_decision_signature(r, verifying_material=HS_SECRET) is True + + +def test_back_link_valid(): + att = _attestation() + r = _emit(att) + assert verify_decision_back_link(r, attestation=att).ok is True + + +def test_back_link_rejects_other_attestation(): + att = _attestation() + r = _emit(att) + other = _attestation() # fresh nonce, different signature + res = verify_decision_back_link(r, attestation=other) + assert res.ok is False + assert res.reason == "back_link_mismatch" + + +def test_back_link_tampered_digest(): + att = _attestation() + r = _emit(att) + bad = dataclasses.replace( + r.back_link, attestation_digest="sha256:" + "0" * 64 + ) + r = dataclasses.replace(r, back_link=bad) + assert verify_decision_back_link(r, attestation=att).ok is False + + +def test_optional_fields_omitted_when_none(): + att = _attestation() + minimal = DecisionDerived(decision="block", decided_at="2026-05-31T09:30:00Z") + r = _emit(att, decision_derived=minimal) + dd = r.to_dict()["decisionDerived"] + assert dd == {"decision": "block", "decidedAt": "2026-05-31T09:30:00Z"} + assert verify_decision_signature(r, verifying_material=HS_SECRET) is True + + +def test_emit_rejects_float_risk_score(): + att = _attestation() + # A float on the wire is the most common signature-drift source and is + # banned by the JCS boundary; the decision record must inherit that ban. + bad = dataclasses.replace(_decision(), risk_score=0.21) # type: ignore[arg-type] + with pytest.raises(Exception): + _emit(att, decision_derived=bad) + + +def test_parse_rejects_invalid_verdict(): + att = _attestation() + d = _emit(att).to_dict() + d["decisionDerived"]["decision"] = "maybe" + with pytest.raises(Exception): + parse_decision_record(d) + + +def test_emit_rejects_bad_digest_prefix(): + att = _attestation() + bl = dataclasses.replace(make_back_link(att), attestation_digest="deadbeef") + with pytest.raises(Exception): + _emit(att, back_link=bl) + + +def test_wire_round_trip(): + att = _attestation() + r = _emit(att) + reparsed = parse_decision_record(r.to_dict()) + assert reparsed == r + assert verify_decision_signature(reparsed, verifying_material=HS_SECRET) is True + assert verify_decision_back_link(reparsed, attestation=att).ok is True + + +def test_decision_and_outcome_pair_on_shared_attestation(): + att = _attestation() + decision = _emit(att) + receipt = emit_receipt( + back_link=make_back_link(att), + outcome_derived=OutcomeDerived( + status="executed", + completed_at="2026-05-31T09:30:02Z", + result_commitment=make_result_digest({"ok": True}), + ), + iss="vaara-proxy://acme-eu", + sub="tenant:acme/agent:billing-bot", + secret_version="2026-05", + alg="HS256", + signing_material=HS_SECRET, + ) + assert records_paired(decision, receipt) is True + + +def test_records_from_different_calls_do_not_pair(): + decision = _emit(_attestation()) + other_att = _attestation() # fresh nonce + receipt = emit_receipt( + back_link=make_back_link(other_att), + outcome_derived=OutcomeDerived( + status="executed", completed_at="2026-05-31T09:30:02Z", + ), + iss="vaara-proxy://acme-eu", + sub="tenant:acme/agent:billing-bot", + secret_version="2026-05", + alg="HS256", + signing_material=HS_SECRET, + ) + assert records_paired(decision, receipt) is False + + +def test_commit_payload_bridges_to_decision_derived(): + from vaara.audit.receipts import CommitPayload, decision_derived_from_commit + + commit = CommitPayload( + action_id="act-1", + decision="deny", + risk_score=0.82, + threshold_allow=0.4, + threshold_deny=0.7, + decided_at=1700000000.0, + ) + dd = decision_derived_from_commit(commit, policy_id="ruleset:v3") + # deny in the audit vocabulary maps to block on the SEP wire. + assert dd.decision == "block" + # Floats become decimal strings; epoch becomes ISO 8601 UTC. + assert dd.risk_score == "0.82" + assert dd.threshold_allow == "0.4" + assert dd.threshold_block == "0.7" + assert dd.decided_at == "2023-11-14T22:13:20Z" + assert dd.policy_id == "ruleset:v3" + + +def test_commit_payload_allow_maps_through(): + from vaara.audit.receipts import CommitPayload, decision_derived_from_commit + + commit = CommitPayload( + action_id="act-2", + decision="allow", + risk_score=0.1, + threshold_allow=0.4, + threshold_deny=0.7, + decided_at=1700000000.0, + ) + dd = decision_derived_from_commit(commit) + assert dd.decision == "allow" + # A bridged decision is a valid signing input under the float ban. + att = _attestation() + r = _emit(att, decision_derived=dd) + assert verify_decision_signature(r, verifying_material=HS_SECRET) is True From 7b5ce1adb1f275c5b7e26f04cb4dd51e05529b83 Mon Sep 17 00:00:00 2001 From: vaaraio <267591518+vaaraio@users.noreply.github.com> Date: Sun, 31 May 2026 13:22:53 +0300 Subject: [PATCH 3/7] test-vectors(receipt): add replay-with-field-substitution negative case Closes the replay-substitution case promised on SEP-2787 (modelcontextprotocol#2787, 2026-05-30). A valid executed HS256 receipt is replayed with one signed field swapped (outcome status executed -> refused) while the original signature is kept. Back-link and result-commitment still verify, so only the signature catches the forgery: the signed envelope, not any single sub-check, binds the outcome claim. HS256 is deterministic, so the case is added without regenerating the ES256/RS256 keys or churning the existing fixtures. Independent walker reports 6/6; guard test bumped to >= 6 cases. Co-Authored-By: Claude Opus 4.8 --- scripts/generate_receipt_vectors.py | 21 ++++++++++++++ tests/test_receipt_vectors.py | 4 +-- tests/vectors/execution_receipt_v0/README.md | 6 ++++ .../attestation.json | 29 +++++++++++++++++++ .../expected.json | 5 ++++ .../neg_replay_substituted_field/receipt.json | 25 ++++++++++++++++ .../runtime_result.json | 4 +++ 7 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/attestation.json create mode 100644 tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/expected.json create mode 100644 tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/receipt.json create mode 100644 tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/runtime_result.json diff --git a/scripts/generate_receipt_vectors.py b/scripts/generate_receipt_vectors.py index 2a814fb..3f8d20c 100644 --- a/scripts/generate_receipt_vectors.py +++ b/scripts/generate_receipt_vectors.py @@ -167,6 +167,27 @@ def main() -> None: expected={"signature_ok": True, "back_link_ok": False, "result_commitment_ok": True}) + # Negative: a valid executed receipt is replayed with one signed field + # substituted (outcome status executed -> refused) while the original + # signature is kept. The back-link and result commitment still verify, so + # only the signature catches the forgery. This is the replay-with-field- + # substitution case (distinct from a stale verifier clock): the signed + # envelope, not any single sub-check, is what binds the outcome claim. + valid = emit_receipt( + back_link=make_back_link(att), + outcome_derived=OutcomeDerived( + status="executed", completed_at=IAT, + result_commitment=make_result_digest(RESULT)), + alg="HS256", signing_material=HS_SECRET, **common) + tampered = valid.to_dict() + tampered["outcomeDerived"]["status"] = "refused" + d = OUT / "normative" / "neg_replay_substituted_field" + _write(d / "attestation.json", att.to_dict()) + _write(d / "receipt.json", tampered) + _write(d / "runtime_result.json", RESULT) + _write(d / "expected.json", {"signature_ok": False, "back_link_ok": True, + "result_commitment_ok": True}) + print(f"wrote vectors under {OUT}") diff --git a/tests/test_receipt_vectors.py b/tests/test_receipt_vectors.py index a264dc9..c5783c4 100644 --- a/tests/test_receipt_vectors.py +++ b/tests/test_receipt_vectors.py @@ -35,6 +35,6 @@ def test_independent_walker_passes_all_cases(): assert _load_checker().main() == 0 -def test_at_least_five_cases_present(): +def test_at_least_six_cases_present(): cases = [p for p in (VECTORS / "normative").iterdir() if p.is_dir()] - assert len(cases) >= 5 + assert len(cases) >= 6 diff --git a/tests/vectors/execution_receipt_v0/README.md b/tests/vectors/execution_receipt_v0/README.md index 051a9ad..39473e6 100644 --- a/tests/vectors/execution_receipt_v0/README.md +++ b/tests/vectors/execution_receipt_v0/README.md @@ -31,6 +31,12 @@ _check_independent.py stdlib + cryptography + rfc8785, no Vaara import commitment does not bind the supplied runtime result. - `neg_broken_backlink`: signature valid, but the receipt pins a different attestation than the one stored, so the back-link fails. +- `neg_replay_substituted_field`: a valid executed receipt is replayed + with one signed field substituted (outcome status `executed` -> + `refused`) while the original signature is kept. The back-link and + result commitment still verify, so only the signature catches the + forgery: the signed envelope, not any single sub-check, is what binds + the outcome claim. ## Verifying diff --git a/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/attestation.json b/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/attestation.json new file mode 100644 index 0000000..eb63e94 --- /dev/null +++ b/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/attestation.json @@ -0,0 +1,29 @@ +{ + "alg": "HS256", + "issuerAsserted": { + "alg": "HS256", + "expSeconds": 300, + "iat": "2026-05-29T10:00:00Z", + "iss": "issuer://test", + "nonce": "fixed-attestation-nonce-000", + "secretVersion": "v1", + "sub": "agent:archiver" + }, + "payloadDerived": { + "toolCalls": [ + { + "args": { + "projection": "{\"digest\":\"sha256:866290b000d2128e0a3188a7207d447090415dc321bfcc0ceed355bd7fd3e8f8\"}", + "projectionDigest": "sha256:031037d68e5734d3c48fa18fb1ed5e0a3b8ec61fa4cf2823b7320436452ca84a" + }, + "name": "delete_file", + "serverFingerprint": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + ] + }, + "plannerDeclared": { + "intent": "archive obsolete report" + }, + "signature": "ea49c2f2e50a2fc58cd7a20bf4ec0e04a225257f7b795e7c999a927c123d4bbd", + "version": 1 +} diff --git a/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/expected.json b/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/expected.json new file mode 100644 index 0000000..ecf85d1 --- /dev/null +++ b/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/expected.json @@ -0,0 +1,5 @@ +{ + "back_link_ok": true, + "result_commitment_ok": true, + "signature_ok": false +} diff --git a/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/receipt.json b/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/receipt.json new file mode 100644 index 0000000..091ea76 --- /dev/null +++ b/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/receipt.json @@ -0,0 +1,25 @@ +{ + "alg": "HS256", + "backLink": { + "attestationDigest": "sha256:79acdd4bb3c22a688b1c3321b9a26cafb5cb58c990a963874066d04b8497f70b", + "attestationNonce": "fixed-attestation-nonce-000" + }, + "outcomeDerived": { + "completedAt": "2026-05-29T10:00:00Z", + "resultCommitment": { + "projection": "{\"digest\":\"sha256:c9a4caed49b3efaa7908a29a550b8d33ffbb088c52d519242477223a83214198\"}", + "projectionDigest": "sha256:4f1d82dd55dad774e144c4f8ae4ced0d891428ffe4858574d706fd736a301b50" + }, + "status": "refused" + }, + "receiptAsserted": { + "alg": "HS256", + "iat": "2026-05-29T10:00:00Z", + "iss": "issuer://test", + "nonce": "fixed-receipt-nonce-0001", + "secretVersion": "v1", + "sub": "agent:archiver" + }, + "signature": "9f9b932ea7fcec52ccc65dd3200d51a9d13d16197677da23c998d5d6f3b1ddc7", + "version": 1 +} diff --git a/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/runtime_result.json b/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/runtime_result.json new file mode 100644 index 0000000..65803da --- /dev/null +++ b/tests/vectors/execution_receipt_v0/normative/neg_replay_substituted_field/runtime_result.json @@ -0,0 +1,4 @@ +{ + "deleted": true, + "path": "/archive/2024-Q3.md" +} From 3e589df040628c1c160d00693f78fa681b69a393 Mon Sep 17 00:00:00 2001 From: vaaraio <267591518+vaaraio@users.noreply.github.com> Date: Sun, 31 May 2026 13:34:50 +0300 Subject: [PATCH 4/7] test(timeanchor): add opt-in live RFC 3161 TSA round-trip Anchors a real AuditTrail chain head against a real public TSA (DigiCert/Sectigo/freeTSA fallback) through anchor_head(), then verifies the token offline bound to the actual chain. Proves the round trip against an authority we do not control, complementing the in-process verifier tests. Skipped unless VAARA_LIVE_TSA=1 so CI and offline runs never depend on a third party. Verified live against http://timestamp.digicert.com: granted a 5999-byte RFC 3161 token, attested time re-derived offline. Co-Authored-By: Claude Opus 4.8 --- tests/test_timeanchor.py | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/test_timeanchor.py b/tests/test_timeanchor.py index b674305..9c5723c 100644 --- a/tests/test_timeanchor.py +++ b/tests/test_timeanchor.py @@ -11,6 +11,7 @@ import datetime import hashlib +import os import pytest @@ -253,3 +254,52 @@ def test_verify_anchor_over_records_rejects_rewritten_chain(): rewritten = ["aa" * 32, "bb" * 32, "cc" * 32] with pytest.raises(TimeAnchorError, match="does not match the trail"): verify_anchor_over_records(anchor, rewritten) + + +# Live network test: anchors a real chain head against a real public RFC 3161 +# TSA, then verifies the returned token offline. Opt-in only (set VAARA_LIVE_TSA) +# so CI and offline runs never depend on a third party. The in-process tests +# above cover the verifier; this proves the round trip against an authority we +# do not control. +_LIVE_TSAS = [ + "http://timestamp.digicert.com", + "http://timestamp.sectigo.com", + "https://freetsa.org/tsr", +] + + +@pytest.mark.skipif( + not os.environ.get("VAARA_LIVE_TSA"), + reason="live TSA round trip; set VAARA_LIVE_TSA=1 to run", +) +def test_anchor_head_against_live_tsa(): + from vaara.audit.trail import AuditTrail + from vaara.taxonomy.actions import ( + ActionCategory, ActionRequest, ActionType, BlastRadius, Reversibility, + ) + + trail = AuditTrail() + trail.record_action_requested(ActionRequest( + action_type=ActionType( + name="live_anchor", category=ActionCategory.DATA, + reversibility=Reversibility.FULLY, blast_radius=BlastRadius.LOCAL, + ), + tool_name="t", agent_id="agent", parameters={}, + )) + + errors = {} + for url in _LIVE_TSAS: + try: + anchor = trail.anchor_head(RFC3161TimeAnchorClient(url, timeout=20.0)) + except TimeAnchorError as exc: + errors[url] = str(exc) + continue + # Offline verification, bound to the real chain head, no network. + record_hashes = [r.record_hash for r in trail._records] + attested = verify_anchor_over_records(anchor, record_hashes) + assert anchor.backend == "rfc3161" + assert anchor.chain_position == trail.size - 1 + assert anchor.chain_head_hash == trail._records[-1].record_hash + assert attested.isoformat() == anchor.anchored_time + return + pytest.fail(f"no live TSA granted/verified: {errors}") From 90c4a678e5909647d61d3d4e56bb223b206a61d5 Mon Sep 17 00:00:00 2001 From: vaaraio <267591518+vaaraio@users.noreply.github.com> Date: Sun, 31 May 2026 13:46:42 +0300 Subject: [PATCH 5/7] feat(audit): automatic cadence anchoring, fail-open with chained gap marker AuditTrail.enable_auto_anchor(client, every_records=N) anchors the chain head to an external TSA every N records, so a deployment no longer has to call anchor_head() by hand. No TSA is configured by default; this is the opt-in that turns anchoring on. Fail-open per design: when the authority is unreachable or its token does not verify, the trail records a chained ANCHOR_GAP marker (reason + the head it tried to anchor) instead of raising, so the unanchored window is itself visible and tamper-evident in the chain. The TSA round trip runs off the hash-chain lock, so it does not block concurrent recording beyond the triggering append; the gap marker appends via _append_chained so it cannot recurse back into anchoring. EventType.ANCHOR_GAP added with a prEN ISO/IEC 12792 transparency default. Tests: cadence fires, fail-open records a chained gap and keeps the chain intact, off by default, cadence validated. Plus an opt-in live TSA test (VAARA_LIVE_TSA=1). Suite 1118 passed / 13 skipped, ruff + mypy clean. Library only; version bump and release are a separate decision. Co-Authored-By: Claude Opus 4.8 --- src/vaara/audit/trail.py | 98 ++++++++++++++++++++++++++++++++++++++++ tests/test_timeanchor.py | 72 +++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) diff --git a/src/vaara/audit/trail.py b/src/vaara/audit/trail.py index 3111c23..91579b9 100644 --- a/src/vaara/audit/trail.py +++ b/src/vaara/audit/trail.py @@ -54,6 +54,7 @@ class EventType(str, Enum): ESCALATION_RESOLVED = "escalation_resolved" # Human responded OUTCOME_RECORDED = "outcome_recorded" # Post-execution outcome observed POLICY_OVERRIDE = "policy_override" # Manual override of policy decision + ANCHOR_GAP = "anchor_gap" # Auto-anchor attempt failed (fail-open marker) # ── Regulatory article mappings ─────────────────────────────────────────── @@ -229,6 +230,11 @@ class RegulatoryArticle: "data_usage": "operator_decision", "decision_making": "human_decision", }, + EventType.ANCHOR_GAP: { + "system_operation": "time_anchoring", + "data_usage": "chain_head_digest", + "decision_making": "n/a", + }, } @@ -560,6 +566,16 @@ def __init__( # chain's existence is provable against an external clock even if the # signing key is later compromised. See vaara.audit.timeanchor. self._anchors: list = [] + # v0.49 automatic cadence anchoring. Once enable_auto_anchor() sets a + # client, the trail anchors its own head every _anchor_cadence records. + # Fail-open: a failed anchor attempt records a chained ANCHOR_GAP marker + # rather than raising, so a TSA outage is itself visible in the chain. + # _anchor_lock guards the cadence counter without widening the chain + # lock (the TSA round trip must happen off the chain lock). + self._anchor_client: Any = None + self._anchor_cadence = 0 + self._records_since_anchor = 0 + self._anchor_lock = threading.Lock() @property def size(self) -> int: @@ -1172,9 +1188,91 @@ def anchor_head(self, client: Any) -> Any: self._anchors.append(anchor) return anchor + def enable_auto_anchor(self, client: Any, *, every_records: int) -> None: + """Anchor the chain head automatically every ``every_records`` records. + + ``client`` is a time-anchor backend (e.g. + ``vaara.audit.timeanchor.RFC3161TimeAnchorClient``). After every + ``every_records`` appended records the trail anchors its current head + to that authority, so a deployment does not have to call + :meth:`anchor_head` by hand. No TSA is configured by default; this is + the opt-in that turns anchoring on. + + Fail-open: if the authority is unreachable or its token does not + verify, the trail records a chained ``ANCHOR_GAP`` marker (carrying the + reason and the head it tried to anchor) instead of raising, so the + unanchored window is itself visible and tamper-evident in the chain. + The TSA round trip runs off the hash-chain lock, so it does not block + concurrent recording beyond the triggering append. + + Raises ``ValueError`` if ``every_records`` is not a positive integer. + """ + if every_records < 1: + raise ValueError("every_records must be a positive integer") + with self._anchor_lock: + self._anchor_client = client + self._anchor_cadence = every_records + self._records_since_anchor = 0 + # ── Internal ────────────────────────────────────────────────── def _append(self, record: AuditRecord) -> None: + """Append a record, then anchor the head if the cadence is due. + + The chaining itself is in :meth:`_append_chained`; this wrapper adds + the automatic-anchor trigger so the gap marker (which appends via + ``_append_chained`` directly) cannot recurse back into anchoring. + """ + self._append_chained(record) + self._maybe_auto_anchor() + + def _maybe_auto_anchor(self) -> None: + """Anchor the head when the per-record cadence is reached (fail-open).""" + if self._anchor_client is None: + return + with self._anchor_lock: + self._records_since_anchor += 1 + if self._records_since_anchor < self._anchor_cadence: + return + self._records_since_anchor = 0 + client = self._anchor_client + with self._lock: + if not self._records: + return + position = len(self._records) - 1 + head_hash = self._records[-1].record_hash + try: + anchor = client.anchor(position, head_hash) + except Exception as exc: # fail-open: never break recording on a TSA fault + self._record_anchor_gap(position, head_hash, repr(exc), client) + return + with self._lock: + self._anchors.append(anchor) + + def _record_anchor_gap( + self, position: int, head_hash: str, reason: str, client: Any + ) -> None: + """Append a chained ANCHOR_GAP marker for a failed auto-anchor attempt.""" + logger.warning( + "auto-anchor failed at chain position %d (%s); recording gap marker", + position, reason, + ) + self._append_chained(AuditRecord( + record_id=str(uuid.uuid4()), + action_id="anchor-gap", + event_type=EventType.ANCHOR_GAP, + timestamp=time.time(), + agent_id="vaara", + tool_name="timeanchor", + data={ + "reason": self._cap_record_str(reason, 512), + "attempted_chain_position": position, + "chain_head_hash": head_hash, + "tsa_url": getattr(client, "tsa_url", ""), + }, + )) + + def _append_chained(self, record: AuditRecord) -> None: """Append a record to the trail with hash chaining.""" # Sanitize caller-supplied `data` at the single choke point so any # record_* method (including direct AuditTrail users bypassing the diff --git a/tests/test_timeanchor.py b/tests/test_timeanchor.py index 9c5723c..c628d03 100644 --- a/tests/test_timeanchor.py +++ b/tests/test_timeanchor.py @@ -303,3 +303,75 @@ def test_anchor_head_against_live_tsa(): assert attested.isoformat() == anchor.anchored_time return pytest.fail(f"no live TSA granted/verified: {errors}") + + +# Automatic cadence-based anchoring (v0.49): once a TSA is configured the +# trail anchors its own head every N records, fail-open with a chained gap +# marker so a TSA outage is itself recorded and provable. + +def _add_actions(trail, n): + from vaara.taxonomy.actions import ( + ActionCategory, ActionRequest, ActionType, BlastRadius, Reversibility, + ) + for i in range(n): + trail.record_action_requested(ActionRequest( + action_type=ActionType( + name=f"a{i}", category=ActionCategory.DATA, + reversibility=Reversibility.FULLY, blast_radius=BlastRadius.LOCAL, + ), + tool_name="t", agent_id="agent", parameters={}, + )) + + +def _failing_tsa_client(): + def transport(url, der_request, timeout): + raise ConnectionError("TSA unreachable") + return RFC3161TimeAnchorClient("https://tsa.example/tsr", transport=transport) + + +def test_auto_anchor_off_by_default(): + from vaara.audit.trail import AuditTrail, EventType + trail = AuditTrail() + _add_actions(trail, 5) + assert trail.anchors == [] + assert not any(r.event_type == EventType.ANCHOR_GAP for r in trail._records) + + +def test_enable_auto_anchor_rejects_non_positive_cadence(): + from vaara.audit.trail import AuditTrail + trail = AuditTrail() + with pytest.raises(ValueError): + trail.enable_auto_anchor(_local_tsa_client(), every_records=0) + + +def test_auto_anchor_fires_on_cadence(): + from vaara.audit.trail import AuditTrail + trail = AuditTrail() + trail.enable_auto_anchor(_local_tsa_client(), every_records=3) + + _add_actions(trail, 3) + assert len(trail.anchors) == 1 + # The anchor binds the head that was current when the cadence tripped. + record_hashes = [r.record_hash for r in trail._records] + assert verify_anchor_over_records(trail.anchors[0], record_hashes) == _GEN_TIME + + _add_actions(trail, 3) + assert len(trail.anchors) == 2 + + +def test_auto_anchor_fail_open_records_chained_gap(): + from vaara.audit.trail import AuditTrail, EventType + trail = AuditTrail() + trail.enable_auto_anchor(_failing_tsa_client(), every_records=3) + + # Recording must not raise even though the TSA is unreachable. + _add_actions(trail, 3) + + gaps = [r for r in trail._records if r.event_type == EventType.ANCHOR_GAP] + assert len(gaps) == 1 + assert "unreachable" in gaps[0].data["reason"].lower() + # No successful anchor, but the chain (including the gap marker) is intact. + assert trail.anchors == [] + assert trail.verify_chain() is None + # The gap marker sits inside the hash chain as the latest record. + assert trail._records[-1].event_type == EventType.ANCHOR_GAP From 0d17d68f2d8fa78caeb596af48c10959ef5d7e74 Mon Sep 17 00:00:00 2001 From: vaaraio <267591518+vaaraio@users.noreply.github.com> Date: Sun, 31 May 2026 13:55:39 +0300 Subject: [PATCH 6/7] docs(sep): drop content-addressed-receipt attribution; time anchor is shipped Removes the named third-party attribution (and the external draft link) from the prior-art and alternatives sections, keeping the technical reasoning for why a content-addressed action_ref is not the default join. Updates the time anchor from "next shipped step" to its shipped state: RFC 3161 over the chain head, offline-verifiable, with optional automatic cadence anchoring that fails open by writing an ANCHOR_GAP marker. Adds the two precision points (anchoring is opt-in with no bundled TSA; offline verify proves the embedded-cert signature, not eIDAS qualification, which is deployer policy via cert pinning). Co-Authored-By: Claude Opus 4.8 --- docs/sep/sep-server-execution-record.md | 43 +++++++++++++++---------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/docs/sep/sep-server-execution-record.md b/docs/sep/sep-server-execution-record.md index af7e460..9c59d7b 100644 --- a/docs/sep/sep-server-execution-record.md +++ b/docs/sep/sep-server-execution-record.md @@ -414,8 +414,8 @@ this is a separate SEP rather than an extension of either. **Alternatives considered.** Carrying the decision inside the SEP-2787 `issuerAsserted` block was rejected because SEP-2787 attests the call, not the verdict, and overloading it would blur the trust surface that SEP made explicit. -Adopting AlgoVoi's content-addressed `action_ref` as the primary binding field -was rejected: a content-addressed receipt id is a useful secondary index but is +Adopting a content-addressed `action_ref` as the primary binding field was +rejected: a content-addressed receipt id is a useful secondary index but is content-binding, and using it as the primary join reintroduces the replay problem instance-binding solves. The `action_ref` style is reconcilable as an optional `ArgsRef`-shaped pointer, not as the default join. @@ -441,11 +441,16 @@ of the append-only audit chain that carries these records to an independent, trusted timestamp (an RFC 3161 timestamp token, or an eIDAS qualified electronic timestamp) so that records signed after a compromise cannot be inserted before the last anchored head without detection. The Vaara reference implementation -binds each record into a hash chain today (chain version 2, with tenant identity -bound into the chained hash), and the external-anchor mechanism is the next -shipped step (Vaara v0.48). A conforming deployment under EU AI Act retention -obligations SHOULD anchor the chain head externally at a cadence proportionate to -its risk. +binds each record into a hash chain (chain version 2, with tenant identity bound +into the chained hash) and ships the external anchor in v0.48: an RFC 3161 +timestamp over the chain head, verifiable offline, with optional automatic cadence +anchoring that fails open by writing a gap marker into the chain when the +authority is unreachable. Anchoring is opt-in. The deployer configures a trusted +timestamp authority; none is bundled by default. Offline verification proves the +token was signed by the certificate it carries; establishing that certificate as +a trusted (for example eIDAS-qualified) authority is deployer policy, enforced by +pinning it. A conforming deployment under EU AI Act retention obligations SHOULD +anchor the chain head externally at a cadence proportionate to its risk. **Replay.** Instance-binding through the SEP-2787 attestation digest (signature included) plus the per-record `nonce` means a record cannot be replayed against a @@ -507,19 +512,19 @@ call and counts emission failures for operator alerting. agent-guard author in discussion on 2026-05-30 and is adopted here as normative. -- **AlgoVoi / chopmob-cloud `action_ref` and `draft-hopley-x402-compliance-receipt`.** - Defines a content-addressed receipt identifier `action_ref = sha256(JCS(...))`. - This SEP does not adopt `action_ref` as the default join field, because a - content-addressed id is content-binding and reintroduces cross-instance replay - when used as the primary binding. A content-addressed pointer is reconcilable - as an optional `ArgsRef`-shaped reference (the `ref` carries such an id and the - `digest` pins it), not as the normative pairing key, which remains the +- **Content-addressed receipt identifiers.** Some designs identify a record by a + content-addressed id of the form `action_ref = sha256(JCS(...))` over a + description of the action. This SEP does not adopt a content-addressed id as the + default join field, because it is content-binding and reintroduces + cross-instance replay when used as the primary binding. Such a pointer is + reconcilable as an optional `ArgsRef`-shaped reference (the `ref` carries the id + and the `digest` pins it), not as the normative pairing key, which remains the instance-scoped `backLink`. ## Reference Implementation The wire schema in this SEP is the shape shipping in the Vaara MCP proxy -(v0.47; the receipt library landed in v0.42). Relevant modules: +(v0.48; the receipt library landed in v0.42). Relevant modules: - `vaara/attestation/_receipt_types.py`: the `ExecutionReceipt` envelope (`version`, `alg`, `backLink`, `outcomeDerived`, `receiptAsserted`, @@ -547,8 +552,12 @@ The wire schema in this SEP is the shape shipping in the Vaara MCP proxy - `vaara/audit/trail.py`: the append-only, hash-chained audit trail the records are written into; chain version 2 binds `tenant_id` and `chain_version` into the chained hash so a record cannot be silently re-attributed to another - tenant. The external time anchor over the chain head (Security Implications) is - the next shipped step (v0.48). + tenant. +- `vaara/audit/timeanchor.py` and `AuditTrail.enable_auto_anchor`: the external + time anchor over the chain head (Security Implications), shipped in v0.48 as an + RFC 3161 client plus an offline verifier, with optional automatic cadence + anchoring that writes an `ANCHOR_GAP` marker into the chain when the authority + is unreachable. **Bridge from the shipped audit decision.** The audit trail already records the pre-execution decision as a hash-chained `CommitPayload` From 0da8823d2147e9cbeaf14df54a015e23008adc9a Mon Sep 17 00:00:00 2001 From: vaaraio <267591518+vaaraio@users.noreply.github.com> Date: Sun, 31 May 2026 18:26:17 +0300 Subject: [PATCH 7/7] release(v0.49.0): decision records, auto cadence anchoring, negative receipt vector Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df63bca..cc135e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +## [0.49.0] - 2026-05-31 + +**Theme: decision records. The evidence chain now covers the full call lifecycle: +policy verdict before execution (decision record), attestation at request time +(SEP-2787), and outcome after execution (execution receipt). A verifier can prove +the governing server committed to allow-or-block before the side effect ran.** + +### Added +- `vaara.attestation.decision` module (`pip install 'vaara[attestation]'`). + `emit_decision_record` signs a `DecisionRecord` envelope that binds the + governing server's policy verdict and risk basis to the SEP-2787 attestation + via a digest back-link, before the tool call executes. Verification is two + composable checks: `verify_decision_signature` (crypto) and + `verify_decision_back_link` (binding to the attestation instance). + `records_paired` joins a decision record to its execution receipt. + Canonicalization and signing (HS256 / ES256 / RS256) reuse the same path as + the receipt and SEP-2787 modules; no new crypto is required. +- `AuditTrail.enable_auto_anchor(client, *, every_records)` for automatic + cadence anchoring. Once enabled the trail anchors its own chain head every N + records without a manual call. Fail-open: a failed anchor attempt writes a + chained `ANCHOR_GAP` marker so the gap is auditable and the trail continues. +- Negative test vector `neg_replay_substituted_field`: verifies that replaying a + receipt with any field substituted fails verification. + +### Fixed +- MCP manifest `description` fields trimmed to the 100-character registry cap + (both `server.json` and `server-vaara-server.json`). + ## [0.48.0] - 2026-05-31 **Theme: external time anchoring. The audit chain head can now be timestamped by diff --git a/pyproject.toml b/pyproject.toml index 16fcb14..97ee87f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "vaara" -version = "0.48.0" +version = "0.49.0" description = "Runtime evidence layer for AI agents under the EU AI Act: policy-gated tool calls, hash-chained tamper-evident audit trails with external time anchoring, and independently verifiable attestation plus execution receipts per MCP tool call" requires-python = ">=3.10" license = "Apache-2.0"