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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 46 additions & 28 deletions docs/sep/sep-server-execution-record.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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`,
Expand All @@ -531,6 +536,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
Expand All @@ -541,19 +552,26 @@ 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).

**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`.
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`
(`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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "vaara"
version = "0.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"
Expand Down
21 changes: 21 additions & 0 deletions scripts/generate_receipt_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")


Expand Down
2 changes: 1 addition & 1 deletion server-vaara-server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
177 changes: 177 additions & 0 deletions src/vaara/attestation/_decision_emit.py
Original file line number Diff line number Diff line change
@@ -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,
)
Comment on lines +62 to +130
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the verdict before signing.

emit_decision_record() trusts decision_derived.decision, but that Literal[...] is only static typing. Today a caller can sign DecisionDerived(decision="deny", ...), producing a record that parse_decision_record() rejects later. Please enforce the same verdict check here before canonicalization.

Suggested fix
 from vaara.attestation._decision_types import (
     DecisionDerived,
     DecisionRecord,
     IssuerAsserted,
+    VALID_VERDICTS,
     decision_to_dict,
 )
@@
     if alg not in VALID_ALGS:
         raise AttestationError(f"unsupported alg: {alg!r}")
+    if decision_derived.decision not in VALID_VERDICTS:
+        raise AttestationError(
+            f"invalid decision verdict {decision_derived.decision!r}"
+        )
     if not back_link.attestation_digest.startswith("sha256:"):
         raise AttestationError(
             "backLink.attestationDigest MUST be a 'sha256:' digest"
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vaara/attestation/_decision_emit.py` around lines 62 - 130,
emit_decision_record currently signs whatever is in DecisionDerived.decision
without runtime validation; before canonicalizing/signing (in
emit_decision_record) assert that decision_derived.decision is one of the
allowed verdict literals (the same set parse_decision_record accepts, e.g.
"allow" or "deny"), and raise AttestationError if not; perform this check right
after the existing back_link/issuer_asserted validations (referencing
emit_decision_record, DecisionDerived.decision, and parse_decision_record) so
malformed decisions are rejected early.



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
Loading