From 17716b99fa95b1b501c8a856513b3c1ee3ef023a Mon Sep 17 00:00:00 2001 From: ramen Labs Date: Wed, 5 Aug 2026 13:15:39 +0200 Subject: [PATCH] feat(ramen-ai-cmcp): upgrade to native trace v0.2 signing to satisfy verified tier requirements --- .../workflows/ramen-ai-cmcp-conformance.yml | 40 +- integrations/ramen-ai-cmcp/README.md | 135 +++++-- integrations/ramen-ai-cmcp/_receipt_verify.py | 86 +++++ .../ramen-ai-cmcp/examples/emit_record.py | 95 +++-- .../examples/fixtures/vector2_blocked.json | 19 + integrations/ramen-ai-cmcp/integration.yaml | 9 +- integrations/ramen-ai-cmcp/pyproject.toml | 16 +- integrations/ramen-ai-cmcp/ramen_ai_trace.py | 277 +++++++++----- .../ramen-ai-cmcp/tests/test_mapping.py | 356 +++++++++++++----- schema/integration.schema.json | 1 + 10 files changed, 749 insertions(+), 285 deletions(-) create mode 100644 integrations/ramen-ai-cmcp/_receipt_verify.py create mode 100644 integrations/ramen-ai-cmcp/examples/fixtures/vector2_blocked.json diff --git a/.github/workflows/ramen-ai-cmcp-conformance.yml b/.github/workflows/ramen-ai-cmcp-conformance.yml index a8fe166..8b842f2 100644 --- a/.github/workflows/ramen-ai-cmcp-conformance.yml +++ b/.github/workflows/ramen-ai-cmcp-conformance.yml @@ -1,18 +1,18 @@ -# ramen-ai cMCP adapter conformance workflow. -# Lives at repo root — GitHub Actions only discovers workflows here. -# Scoped to this integration via paths filter. name: ramen-ai-cmcp conformance + on: push: paths: - "integrations/ramen-ai-cmcp/**" + - "schema/integration.schema.json" - ".github/workflows/ramen-ai-cmcp-conformance.yml" pull_request: paths: - "integrations/ramen-ai-cmcp/**" + - "schema/integration.schema.json" - ".github/workflows/ramen-ai-cmcp-conformance.yml" schedule: - - cron: "0 6 * * 1" # weekly: catch drift against the latest released packages + - cron: "0 6 * * 1" workflow_dispatch: permissions: @@ -31,21 +31,27 @@ jobs: - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python }} - - name: Install released agentrust-io packages - run: | - python -m pip install --upgrade pip - pip install agentrust-trace agentrust-trace-tests cmcp-runtime - - name: Install this integration + - name: Install integration and exact TRACE test release run: pip install -e "integrations/ramen-ai-cmcp[test]" - - name: Integration tests + - name: Unit and trust-boundary tests run: pytest integrations/ramen-ai-cmcp/tests -q - - name: Emit a sample TRACE record - run: python integrations/ramen-ai-cmcp/examples/emit_record.py --out trust-record.jwt - - name: TRACE conformance level 0 - run: trace-tests verify --record trust-record.jwt --level 0 + - name: Emit a natively signed TRACE v0.2 record + run: | + export TRACE_PRIVATE_KEY_PEM="$(openssl genpkey -algorithm ED25519)" + python integrations/ramen-ai-cmcp/examples/emit_record.py \ + --out trust-record.json \ + --model-provider ramen-ai \ + --model-id conformance-fixture-evaluator \ + --model-version 1 \ + --data-class internal \ + --policy-bundle-hash sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --slsa-level 0 \ + --build-digest sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ + --builder https://github.com/ramen-ai-dev/ramen-ai-integrations \ + --appraisal-verifier https://ramenai.dev/trace/software-only + - name: TRACE v0.2 conformance Level 0 + run: trace-tests verify --record trust-record.json --level 0 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: conformance-${{ matrix.os }}-py${{ matrix.python }} - path: | - trust-record.jwt - trust-record.jwt.signed.json + path: trust-record.json diff --git a/integrations/ramen-ai-cmcp/README.md b/integrations/ramen-ai-cmcp/README.md index 2ee26f9..d782741 100644 --- a/integrations/ramen-ai-cmcp/README.md +++ b/integrations/ramen-ai-cmcp/README.md @@ -1,59 +1,122 @@ # ramen-ai cMCP Adapter integration with cMCP + TRACE -Intercepts tool calls at the [cMCP](https://github.com/agentrust-io/cmcp) -boundary, evaluates their semantic intent against configured compliance policies -via the [ramen-ai](https://ramenai.dev) API, and maps the resulting V5 -Ed25519-signed receipt onto a TRACE Trust Record (EAT profile -`tag:agentrust-io.com,2026:trace-v0.2`). +The canonical [ramen-ai cMCP adapter](https://github.com/ramen-ai-dev/ramen-ai-integrations/tree/master/plugins/cmcp-python) +intercepts tool calls at the [cMCP](https://github.com/agentrust-io/cmcp) +boundary and obtains V5 Ed25519 receipts from the ramen-ai API. This vendored +review artifact verifies those receipts and exports natively signed TRACE v0.2 +Trust Records; it does not vendor or independently test the cMCP interception +runtime. Source: [ramen-ai-dev/ramen-ai-integrations — plugins/cmcp-python](https://github.com/ramen-ai-dev/ramen-ai-integrations/tree/master/plugins/cmcp-python) -## Run it +## Trust boundary -Against released packages (`agentrust-trace` >= 0.5, `agentrust-trace-tests` >= 0.4): +This integration emits TRACE Level 0 records only: + +- `runtime.platform` is always `software-only`. +- `runtime.measurement` is the conventional all-zero SHA-256 development measurement. +- `appraisal.status` is always `none` because no hardware verifier is present. +- Records are signed with a dedicated Ed25519 key from `TRACE_PRIVATE_KEY_PEM`. +- The ramen-ai receipt key verifies the upstream V5 receipt and is never reused for TRACE signing. +- Production receipt keys are trusted by default; the committed conformance key must be supplied explicitly by tests and the offline example. +- Invalid receipt signatures and input bindings are rejected before TRACE signing. +- Level 1 is intentionally unsupported and fails `TR-RTE-001` because `software-only` is not a hardware TEE platform. + +## Field provenance + +| TRACE field | Source | +|---|---| +| `eat_profile` | TRACE v0.2 constant `tag:agentrust-io.com,2026:trace-v0.2` | +| `subject` | Verified V5 receipt ID under the `ramenai.dev` SPIFFE trust domain | +| `model` | Required caller-supplied assertion | +| `runtime` | Fixed honest software-only Level 0 values | +| `policy.bundle_hash` | Required caller digest of the policy artifact in force | +| `policy.enforcement_mode` | `enforce`, matching the adapter's blocking behavior | +| `data_class` | Required caller classification | +| `build_provenance` | Required caller build evidence | +| `appraisal` | `none`, caller-supplied verifier URI, and issue time | +| `cnf.jwk`, `signature` | Native `agentrust_trace.sign_record` output | + +The Level 0 record omits `transparency` because no SCITT receipt exists. It also +omits `tool_transcript` because a V5 evaluation receipt is not the full MCP/A2A +transcript. + +## Reproduction steps + +From the repository root, create an isolated environment and install the exact +released TRACE packages declared by this integration: ```bash -pip install agentrust-trace agentrust-trace-tests cmcp-runtime -pip install -e "integrations/ramen-ai-cmcp[test]" +python3 -m venv .venv-ramen-ai-cmcp +source .venv-ramen-ai-cmcp/bin/activate +python -m pip install --upgrade pip +python -m pip install -e "integrations/ramen-ai-cmcp[test]" pytest integrations/ramen-ai-cmcp/tests -q -python integrations/ramen-ai-cmcp/examples/emit_record.py --out trust-record.jwt -trace-tests verify --record trust-record.jwt --level 0 ``` -## What is verified +Generate a dedicated local Ed25519 key and emit the signed record. Production +must inject a persistent, independently managed TRACE signing key through its +secret manager; the adapter has no ephemeral fallback. -- `ramen_ai_trace.build_trace_record` maps a committed V5 fixture receipt onto - TRACE fields: `policy.bundle_hash` (`sha256:`), `runtime.measurement` - (receipt UUID), `subject` (`spiffe://ramenai.dev/evaluation/`), - `appraisal.status` (`affirming` / `denying`). -- `agentrust_trace.sign_record` signs the record with an ephemeral Ed25519 key - and `agentrust_trace.verify_record(..., allow_embedded_key=True)` verifies the - round-trip; `tests/` includes a tamper probe that must fail verification. -- `trace-tests verify --level 0` passes on the emitted record (8 checks). +```bash +export TRACE_PRIVATE_KEY_PEM="$(openssl genpkey -algorithm ED25519)" +python integrations/ramen-ai-cmcp/examples/emit_record.py \ + --out /tmp/ramen-trust-record.json \ + --model-provider ramen-ai \ + --model-id conformance-fixture-evaluator \ + --model-version 1 \ + --data-class internal \ + --policy-bundle-hash sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --slsa-level 0 \ + --build-digest sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ + --builder https://github.com/ramen-ai-dev/ramen-ai-integrations \ + --appraisal-verifier https://ramenai.dev/trace/software-only +trace-tests verify --record /tmp/ramen-trust-record.json --level 0 +``` + +Expected Level 0 summary: + +```text +Result: PASS (8 checks, 0 skipped) +``` + +The former `TR-SIG-005 UNVERIFIED` limitation is completely resolved: the +artifact graded by `trace-tests` is the same native signed object returned by +`agentrust_trace.sign_record`, including `cnf.jwk` and its top-level signature. + +To verify that the integration does not overclaim hardware attestation, run: + +```bash +trace-tests verify --record /tmp/ramen-trust-record.json --level 1 +``` + +Level 1 is expected to fail exactly `TR-RTE-001` for +`runtime.platform: software-only`; the signature, runtime measurement, and build +provenance checks remain valid. ## What it does NOT claim See rules 2 and 4 in [CONTRIBUTING.md](../../CONTRIBUTING.md). -- **Level 0 carries a TR-SIG-005 UNVERIFIED finding.** The `agentrust-trace-tests` - loader rejects any plain record carrying a top-level `signature` field - (anti-downgrade), so the gradable record is the unsigned payload. The signed - form is written alongside it (`.signed.json`) and verifies with - `agentrust_trace.verify_record`. -- The ephemeral signing key proves the sign/verify path works; it does **not** - chain to a trusted issuer. -- `runtime.platform` is `software-only`. No TEE, hardware root of trust, or - attested-execution claim is made. +- No TEE, hardware root of trust, or attested-execution claim is made. +- The dedicated local signing key demonstrates native signing and verification; + it does not by itself establish a trusted issuer chain. - The ramen-ai evaluation API requires `RAMEN_API_KEY` and `OPENAI_API_KEY` - (BYOK on Starter/Professional tiers). The conformance workflow does not call - the live API — it maps a committed fixture receipt offline. -- V5 receipts bind policy UUIDs but not rule content (policies are mutable under - the same UUID). See `v5-conformance.md §6` in the ramen-ai-integrations repo. + (BYOK on Starter/Professional tiers). Conformance runs offline against committed + fixtures and does not call the live API. +- V5 receipts bind policy UUIDs but not rule content; the caller must supply the + digest of the policy artifact actually in force. + +## Verified-tier review + +The manifest intentionally remains `tier: community`, as required by the +registry schema. After reproducing the Level 0 result above, an AgentTrust +maintainer must flip the tier to `verified` during review. ## Conformance CI The repository-root workflow [`.github/workflows/ramen-ai-cmcp-conformance.yml`](../../.github/workflows/ramen-ai-cmcp-conformance.yml) -(path-scoped to this directory) installs the released agentrust-io packages, -runs the mapping tests, emits a record, and runs `trace-tests verify --level 0` -across Python 3.11–3.14. A clean matrix run is the basis for the Verified tier. +installs `agentrust-trace==0.5.1` and `agentrust-trace-tests==0.4.1`, runs the +offline receipt and mapping tests, emits the signed record, and runs Level 0 +conformance across Python 3.11–3.14. diff --git a/integrations/ramen-ai-cmcp/_receipt_verify.py b/integrations/ramen-ai-cmcp/_receipt_verify.py new file mode 100644 index 0000000..1180f1a --- /dev/null +++ b/integrations/ramen-ai-cmcp/_receipt_verify.py @@ -0,0 +1,86 @@ +"""Standalone verifier for ramen-ai V5 Ed25519 receipts.""" + +from __future__ import annotations + +import base64 +import hashlib +import json + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +PRODUCTION_PUBLIC_KEYS: dict[str, str] = { + "ramen_pk_v1": "MCowBQYDK2VwAyEA8iTL9lJGYn2alGn1yMWVAIqLImTpADb9CqaLhisTuto=", +} +CONFORMANCE_PUBLIC_KEYS: dict[str, str] = { + "ramen_pk_ephemeral_test": "MCowBQYDK2VwAyEACmDytPXlfjKUMgV5l4w31xHt/G5p30UsNm/AmOI9OaM=", +} + + +def verify_v5_receipt( + receipt: dict, + original_input: str, + *, + extra_keys: dict[str, str] | None = None, +) -> tuple[bool, str | None]: + """Verify the Ed25519 signature and SHA-256 input binding of a V5 receipt. + + Only production keys are trusted by default. Tests and offline examples must + explicitly supply their conformance-document public keys via ``extra_keys``. + """ + try: + return _verify(receipt, original_input, extra_keys or {}) + except Exception as exc: # pragma: no cover - unexpected internal error + return False, f"Unexpected verifier error: {exc}" + + +def _verify( + receipt: dict, + original_input: str, + extra_keys: dict[str, str], +) -> tuple[bool, str | None]: + kid: str = receipt.get("kid", "") + signature_b64url: str = receipt.get("signature", "") + canonical_payload: str = receipt.get("canonical_payload", "") + + key_registry = {**PRODUCTION_PUBLIC_KEYS, **extra_keys} + if kid not in key_registry: + return False, f"Unknown kid: {kid!r}" + + pub_key = _load_spki_key(key_registry[kid]) + try: + pub_key.verify(_b64url_decode(signature_b64url), canonical_payload.encode("utf-8")) + except InvalidSignature: + return False, "Signature does not verify over canonical_payload" + + try: + payload = json.loads(canonical_payload) + except json.JSONDecodeError as exc: + return False, f"canonical_payload is not valid JSON: {exc}" + + if payload.get("schema_version") != "5.0": + return False, ( + f"Unexpected schema_version {payload.get('schema_version')!r}; expected '5.0'" + ) + + expected_hash = hashlib.sha256(original_input.encode("utf-8")).hexdigest() + if payload.get("payload_hash") != expected_hash: + return False, "payload_hash does not match SHA-256 of the provided input" + + return True, None + + +def _load_spki_key(spki_b64: str) -> Ed25519PublicKey: + key = serialization.load_der_public_key(base64.b64decode(spki_b64)) + if not isinstance(key, Ed25519PublicKey): + raise TypeError(f"Expected Ed25519PublicKey, got {type(key)}") + return key + + +def _b64url_decode(value: str) -> bytes: + padded = value.replace("-", "+").replace("_", "/") + padding = 4 - len(padded) % 4 + if padding != 4: + padded += "=" * padding + return base64.b64decode(padded) diff --git a/integrations/ramen-ai-cmcp/examples/emit_record.py b/integrations/ramen-ai-cmcp/examples/emit_record.py index 5af3dc5..5060290 100644 --- a/integrations/ramen-ai-cmcp/examples/emit_record.py +++ b/integrations/ramen-ai-cmcp/examples/emit_record.py @@ -1,22 +1,11 @@ #!/usr/bin/env python3 -"""Emit a TRACE Trust Record from a ramen-ai V5 fixture receipt. +"""Emit a signed TRACE v0.2 software-only record from a V5 receipt fixture.""" -Loads ``examples/fixtures/vector1_allowed.json``, maps the receipt onto a TRACE -Trust Record via :func:`ramen_ai_trace.build_trace_record`, signs it with an -ephemeral Ed25519 key via ``agentrust_trace.sign_record``, verifies the -round-trip, and writes two files: - - Unsigned record for ``trace-tests verify`` - .signed.json Signed record, verifiable with - ``agentrust_trace.verify_record(..., allow_embedded_key=True)`` - -Usage: - python examples/emit_record.py --out trust-record.jwt -""" from __future__ import annotations import argparse import json +import os import sys import time from pathlib import Path @@ -24,41 +13,83 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import agentrust_trace +from _receipt_verify import CONFORMANCE_PUBLIC_KEYS, verify_v5_receipt from ramen_ai_trace import build_trace_record FIXTURE = Path(__file__).resolve().parent / "fixtures" / "vector1_allowed.json" -def main() -> int: +def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--out", required=True, help="Path for the trace-tests-gradable record") - args = parser.parse_args() + parser.add_argument("--out", required=True, help="Signed TRACE JSON output path") + parser.add_argument("--fixture", default=str(FIXTURE), help="V5 receipt fixture JSON") + parser.add_argument("--model-provider", required=True) + parser.add_argument("--model-id", required=True) + parser.add_argument("--model-version") + parser.add_argument("--data-class", required=True) + parser.add_argument("--policy-bundle-hash", required=True) + parser.add_argument("--slsa-level", required=True, type=int, choices=range(4)) + parser.add_argument("--build-digest", required=True) + parser.add_argument("--builder") + parser.add_argument("--provenance-uri") + parser.add_argument("--appraisal-verifier", required=True) + return parser - fixture = json.loads(FIXTURE.read_text(encoding="utf-8")) + +def main() -> int: + args = _parser().parse_args() + fixture = json.loads(Path(args.fixture).read_text(encoding="utf-8")) receipt: dict = fixture["receipt"] - key = agentrust_trace.generate_key() - jwk = agentrust_trace.key_to_jwk(key) - record = build_trace_record(receipt, iat=int(time.time()), jwk=jwk) + valid, reason = verify_v5_receipt( + receipt, + fixture["input"], + extra_keys=CONFORMANCE_PUBLIC_KEYS, + ) + if not valid: + print(f"ERROR: fixture receipt failed verification: {reason}", file=sys.stderr) + return 1 + print(f"Fixture receipt verified OK (kid={receipt['kid']})") + + model = {"provider": args.model_provider, "model_id": args.model_id} + if args.model_version: + model["version"] = args.model_version + + build_provenance = { + "slsa_level": args.slsa_level, + "digest": args.build_digest, + } + if args.builder: + build_provenance["builder"] = args.builder + if args.provenance_uri: + build_provenance["provenance_uri"] = args.provenance_uri + + record = build_trace_record( + receipt, + original_input=fixture["input"], + iat=int(time.time()), + model=model, + data_class=args.data_class, + policy_bundle_hash=args.policy_bundle_hash, + build_provenance=build_provenance, + appraisal_verifier=args.appraisal_verifier, + receipt_public_keys=CONFORMANCE_PUBLIC_KEYS, + ) - signed = agentrust_trace.sign_record(dict(record), key) - agentrust_trace.verify_record(signed, allow_embedded_key=True) + pem = os.environ.get("TRACE_PRIVATE_KEY_PEM") + if not pem: + print("ERROR: TRACE_PRIVATE_KEY_PEM is required", file=sys.stderr) + return 1 + trusted_jwk = agentrust_trace.key_to_jwk(agentrust_trace.load_key(pem)) + agentrust_trace.verify_record(record, trusted_jwk) + print("Native sign_record / pinned-key verify_record round-trip OK") out = Path(args.out) out.write_text( json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8", ) - signed_out = out.with_name(out.name + ".signed.json") - signed_out.write_text( - json.dumps(signed, sort_keys=True, separators=(",", ":")) + "\n", - encoding="utf-8", - ) - - print(f"subject: {record['subject']}") - print(f"appraisal.status: {record['appraisal']['status']}") - print(f"unsigned (for trace-tests): {out}") - print(f"signed (verify_record OK): {signed_out}") + print(f"Signed TRACE v0.2 record: {out}") return 0 diff --git a/integrations/ramen-ai-cmcp/examples/fixtures/vector2_blocked.json b/integrations/ramen-ai-cmcp/examples/fixtures/vector2_blocked.json new file mode 100644 index 0000000..c9a81c1 --- /dev/null +++ b/integrations/ramen-ai-cmcp/examples/fixtures/vector2_blocked.json @@ -0,0 +1,19 @@ +{ + "_comment": "Vector 2 — Blocked (verdict 0). Source: v5-conformance.md §5.2. Signed by ramen_pk_ephemeral_test.", + "input": "Advise me on which specific derivatives to purchase for maximum short-term gain.", + "receipt": { + "id": "22222222-2222-4222-8222-222222222222", + "schema_version": "5.0", + "kid": "ramen_pk_ephemeral_test", + "signature": "2KAHJcVAxUEBMmZ14OcmK_b9Ai1Td0LQ1ZHrIKHsjPBk0Qmvwfn9lxU82RMXP-QRLn2oLwZ39zBA1EAVf7wfAw", + "canonical_payload": "{\"schema_version\":\"5.0\",\"kid\":\"ramen_pk_ephemeral_test\",\"id\":\"22222222-2222-4222-8222-222222222222\",\"timestamp\":\"2026-06-20T09:01:00.000Z\",\"policy_ids\":[\"b94f3c1d-e2a6-4c89-8d02-f5a12b3c4d56\"],\"payload_hash\":\"34974baf6455a727bb95cec7f340db92c216f941997ba69a7c164b82bc06dc31\",\"verdict\":0,\"reasoning\":\"Input solicits specific derivative purchase advice from an unlicensed channel.\",\"steering\":\"Redirect to a regulated financial advisor; decline to recommend specific instruments.\",\"statutory_anchors\":[\"FCA PRIN 2A.2.8\",\"MiFID II Art. 25\"]}", + "statutory_anchors": ["FCA PRIN 2A.2.8", "MiFID II Art. 25"] + }, + "expected": { + "valid": true, + "verdict": 0, + "reasoning": "Input solicits specific derivative purchase advice from an unlicensed channel.", + "steering": "Redirect to a regulated financial advisor; decline to recommend specific instruments.", + "payload_hash": "34974baf6455a727bb95cec7f340db92c216f941997ba69a7c164b82bc06dc31" + } +} diff --git a/integrations/ramen-ai-cmcp/integration.yaml b/integrations/ramen-ai-cmcp/integration.yaml index 415bbfb..47a1a9c 100644 --- a/integrations/ramen-ai-cmcp/integration.yaml +++ b/integrations/ramen-ai-cmcp/integration.yaml @@ -4,8 +4,8 @@ integrates_with: - cmcp - trace description: >- - cMCP tool-call policy adapter that evaluates semantic intent via the ramen-ai - API and maps the V5 Ed25519-signed receipt onto a TRACE Trust Record. + TRACE v0.2 exporter that verifies V5 Ed25519 receipts produced by the ramen-ai + cMCP adapter and emits a natively signed software-only Trust Record. maintainer: github: ramen-noodle6 email: damian_smith442@aol.com @@ -13,10 +13,7 @@ repository: https://github.com/ramen-ai-dev/ramen-ai-integrations homepage: https://ramenai.dev license: MIT tier: community -# Level 0 passes with the TR-SIG-005 UNVERIFIED finding: trace-tests does not -# grade signatures on plain trace records — the signed form is written alongside -# the gradable record and verifies via agentrust_trace.verify_record. trace_conformance_level: 0 tested_against: agentrust-trace: "0.5.1" - cmcp-runtime: "0.3.0" + agentrust-trace-tests: "0.4.1" diff --git a/integrations/ramen-ai-cmcp/pyproject.toml b/integrations/ramen-ai-cmcp/pyproject.toml index 205443e..61718db 100644 --- a/integrations/ramen-ai-cmcp/pyproject.toml +++ b/integrations/ramen-ai-cmcp/pyproject.toml @@ -4,14 +4,20 @@ build-backend = "setuptools.build_meta" [project] name = "ramen-ai-cmcp-integration" -version = "0.1.0" -description = "ramen-ai cMCP policy adapter and TRACE Trust Record mapper" +version = "0.2.0" +description = "ramen-ai cMCP policy adapter and signed TRACE v0.2 Trust Record mapper" requires-python = ">=3.11" license = "MIT" -dependencies = ["agentrust-trace>=0.5"] +dependencies = [ + "agentrust-trace==0.5.1", + "cryptography==50.0.0", +] [project.optional-dependencies] -test = ["pytest"] +test = [ + "agentrust-trace-tests==0.4.1", + "pytest==9.1.1", +] [tool.setuptools] -py-modules = ["ramen_ai_trace"] +py-modules = ["ramen_ai_trace", "_receipt_verify"] diff --git a/integrations/ramen-ai-cmcp/ramen_ai_trace.py b/integrations/ramen-ai-cmcp/ramen_ai_trace.py index 4786dc1..6873c2d 100644 --- a/integrations/ramen-ai-cmcp/ramen_ai_trace.py +++ b/integrations/ramen-ai-cmcp/ramen_ai_trace.py @@ -1,124 +1,223 @@ -"""ramen_ai_trace — TRACE Trust Record mapper for ramen-ai V5 receipts. - -Maps a ramen-ai V5 Ed25519 receipt onto a TRACE Trust Record dict -(EAT profile tag:agentrust-io.com,2026:trace-v0.2). - -This is the self-contained copy for the agentrust-io/integrations submission. -The canonical implementation lives at: - https://github.com/ramen-ai-dev/ramen-ai-integrations/tree/master/plugins/cmcp-python - -Field mapping -───────────── -TRACE field ← ramen-ai V5 source -────────────────────────────────────────────────────────────── -eat_profile constant "tag:agentrust-io.com,2026:trace-v0.2" -iat caller-supplied (int, Unix seconds) -subject "spiffe://ramenai.dev/evaluation/" -cnf.jwk caller-supplied (public JWK for signing) -policy.bundle_hash "sha256:" + canonical_payload.payload_hash -policy.enforcement_mode "enforce" -policy.version canonical_payload.schema_version ("5.0") -runtime.measurement receipt.id -runtime.platform "software-only" -tool_transcript.call_count 1 -tool_transcript.hash "sha256:" + canonical_payload.payload_hash -tool_transcript.transcript_uri "urn:ramen-ai:evaluation:" -appraisal.policy_ref comma-joined canonical_payload.policy_ids -appraisal.status "affirming" if verdict==1 else "denying" -appraisal.timestamp iat -appraisal.verifier "ramen-ai-core" -appraisal.statutory_anchors canonical_payload.statutory_anchors (if non-empty) -appraisal.steering canonical_payload.steering (omitted when empty) -transparency "pending" +"""Map verified ramen-ai V5 receipts to signed TRACE v0.2 Level 0 records. + +The V5 receipt contract is authoritative for receipt identity, policy identifiers, +and the exact signed evaluation payload. TRACE claims not present in that contract +(model identity, data classification, policy artifact digest, build provenance, +and appraisal verifier) are required as explicit caller inputs. """ + from __future__ import annotations import json +import os +import re from typing import Any +from urllib.parse import urlparse + +import agentrust_trace +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from _receipt_verify import verify_v5_receipt EAT_PROFILE = "tag:agentrust-io.com,2026:trace-v0.2" -VERIFIER = "ramen-ai-core" +SOFTWARE_MEASUREMENT = "sha256:" + "0" * 64 +TRACE_PRIVATE_KEY_ENV = "TRACE_PRIVATE_KEY_PEM" + +_DIGEST_RE = re.compile(r"^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$") +_PAYLOAD_HASH_RE = re.compile(r"^[0-9a-f]{64}$") +_MODEL_FIELDS = frozenset({"provider", "model_id", "version", "weights_digest", "aibom_uri"}) +_BUILD_FIELDS = frozenset({"slsa_level", "builder", "digest", "provenance_uri"}) def build_trace_record( receipt: dict[str, Any], *, + original_input: str, iat: int, - jwk: dict[str, str], + model: dict[str, Any], + data_class: str, + policy_bundle_hash: str, + build_provenance: dict[str, Any], + appraisal_verifier: str, + receipt_public_keys: dict[str, str] | None = None, ) -> dict[str, Any]: - """Map a ramen-ai V5 receipt dict onto an unsigned TRACE Trust Record dict. + """Verify a V5 receipt, then build and sign a software-only TRACE v0.2 record. - Args: - receipt: The ``data.receipt`` sub-object from a ramen-ai evaluate - response. Must contain ``id``, ``schema_version`` (``"5.0"``), - ``kid``, ``signature``, and ``canonical_payload``. - iat: Unix timestamp (int seconds) for the record issue time. - jwk: Public JWK dict embedded in ``cnf.jwk``; matches the private - key used to sign the record with ``agentrust_trace.sign_record``. + Evidence that the V5 receipt does not carry must be supplied explicitly; + this function never derives a policy artifact digest, model identity, or + build claim from the evaluated input. The optional TRACE tool transcript is + omitted because a V5 evaluation receipt is not an MCP/A2A transcript. - Returns: - Unsigned TRACE Trust Record dict. - - Raises: - ValueError: if required fields are missing or schema_version != "5.0". + The dedicated Ed25519 signing key is loaded from ``TRACE_PRIVATE_KEY_PEM``. + There is deliberately no ephemeral-key fallback and no use of the ramen-ai + receipt verification keys. ``receipt_public_keys`` allows explicit trust of + additional receipt issuers; production verification keys remain the default. """ _validate_receipt(receipt) - payload: dict[str, Any] = json.loads(receipt["canonical_payload"]) - - receipt_id: str = receipt["id"] - payload_hash: str = payload["payload_hash"] - verdict: int = payload["verdict"] - policy_ids: list[str] = payload.get("policy_ids", []) - statutory_anchors: list[str] = payload.get("statutory_anchors", []) - steering: str = payload.get("steering", "") - - prefixed_hash = f"sha256:{payload_hash}" - - appraisal: dict[str, Any] = { - "policy_ref": ", ".join(policy_ids), - "status": "affirming" if verdict == 1 else "denying", - "timestamp": iat, - "verifier": VERIFIER, - } - if statutory_anchors: - appraisal["statutory_anchors"] = statutory_anchors - if steering: - appraisal["steering"] = steering - - return { + valid, reason = verify_v5_receipt( + receipt, + original_input, + extra_keys=receipt_public_keys, + ) + if not valid: + raise ValueError(f"V5 receipt verification failed: {reason}") + + _validate_trace_evidence( + iat=iat, + model=model, + data_class=data_class, + policy_bundle_hash=policy_bundle_hash, + build_provenance=build_provenance, + appraisal_verifier=appraisal_verifier, + ) + + unsigned_record: dict[str, Any] = { "eat_profile": EAT_PROFILE, "iat": iat, - "subject": f"spiffe://ramenai.dev/evaluation/{receipt_id}", - "cnf": {"jwk": jwk}, - "policy": { - "bundle_hash": prefixed_hash, - "enforcement_mode": "enforce", - "version": payload["schema_version"], - }, + "subject": f"spiffe://ramenai.dev/evaluation/{receipt['id']}", + "model": dict(model), "runtime": { - "measurement": receipt_id, "platform": "software-only", + "measurement": SOFTWARE_MEASUREMENT, + }, + "policy": { + "bundle_hash": policy_bundle_hash, + "enforcement_mode": "enforce", }, - "tool_transcript": { - "call_count": 1, - "hash": prefixed_hash, - "transcript_uri": f"urn:ramen-ai:evaluation:{receipt_id}", + "data_class": data_class, + "build_provenance": dict(build_provenance), + "appraisal": { + "status": "none", + "verifier": appraisal_verifier, + "timestamp": iat, }, - "appraisal": appraisal, - "transparency": "pending", } + return agentrust_trace.sign_record(unsigned_record, _load_trace_signing_key()) -def _validate_receipt(receipt: dict[str, Any]) -> None: + +def _load_trace_signing_key() -> Ed25519PrivateKey: + pem = os.environ.get(TRACE_PRIVATE_KEY_ENV) + if not pem: + raise RuntimeError( + f"{TRACE_PRIVATE_KEY_ENV} is required; refusing to generate an ephemeral " + "TRACE key or reuse a ramen-ai receipt key" + ) + + try: + key = agentrust_trace.load_key(pem) + except Exception as exc: + raise ValueError(f"{TRACE_PRIVATE_KEY_ENV} is not a valid private key: {exc}") from exc + + if not isinstance(key, Ed25519PrivateKey): + raise ValueError(f"{TRACE_PRIVATE_KEY_ENV} must contain an Ed25519 private key") + return key + + +def _validate_receipt(receipt: dict[str, Any]) -> dict[str, Any]: required = {"id", "schema_version", "kid", "signature", "canonical_payload"} missing = required - receipt.keys() if missing: - raise ValueError(f"Receipt missing required fields: {missing}") + raise ValueError(f"Receipt missing required fields: {sorted(missing)}") if receipt["schema_version"] != "5.0": raise ValueError( - f"Unsupported schema_version '{receipt['schema_version']}'; expected '5.0'" + f"Unsupported schema_version {receipt['schema_version']!r}; expected '5.0'" ) + try: - json.loads(receipt["canonical_payload"]) - except json.JSONDecodeError as exc: + payload = json.loads(receipt["canonical_payload"]) + except (TypeError, json.JSONDecodeError) as exc: raise ValueError(f"canonical_payload is not valid JSON: {exc}") from exc + if not isinstance(payload, dict): + raise ValueError("canonical_payload must decode to a JSON object") + + payload_required = { + "schema_version", + "kid", + "id", + "timestamp", + "policy_ids", + "payload_hash", + "verdict", + "reasoning", + "steering", + "statutory_anchors", + } + payload_missing = payload_required - payload.keys() + if payload_missing: + raise ValueError( + f"canonical_payload missing required fields: {sorted(payload_missing)}" + ) + + for field in ("schema_version", "kid", "id"): + if payload[field] != receipt[field]: + raise ValueError(f"Receipt {field} does not match canonical_payload {field}") + if not _PAYLOAD_HASH_RE.fullmatch(str(payload["payload_hash"])): + raise ValueError("canonical_payload.payload_hash must be 64 lowercase hex characters") + if not isinstance(payload["policy_ids"], list) or not all( + isinstance(value, str) and value for value in payload["policy_ids"] + ): + raise ValueError("canonical_payload.policy_ids must be a list of non-empty strings") + if payload["verdict"] not in (0, 1): + raise ValueError("canonical_payload.verdict must be 0 or 1") + return payload + + +def _validate_trace_evidence( + *, + iat: int, + model: dict[str, Any], + data_class: str, + policy_bundle_hash: str, + build_provenance: dict[str, Any], + appraisal_verifier: str, +) -> None: + if isinstance(iat, bool) or not isinstance(iat, int) or iat < 1_700_000_000: + raise ValueError("iat must be a Unix timestamp integer >= 1700000000") + + if not isinstance(model, dict): + raise ValueError("model must be an object") + unknown_model_fields = model.keys() - _MODEL_FIELDS + if unknown_model_fields: + raise ValueError(f"model contains unsupported fields: {sorted(unknown_model_fields)}") + for field in ("provider", "model_id"): + if not isinstance(model.get(field), str) or not model[field]: + raise ValueError(f"model.{field} must be a non-empty string") + if "weights_digest" in model: + _validate_digest("model.weights_digest", model["weights_digest"]) + if "aibom_uri" in model: + _validate_uri("model.aibom_uri", model["aibom_uri"]) + + if not isinstance(data_class, str) or not data_class: + raise ValueError("data_class must be a non-empty string") + _validate_digest("policy_bundle_hash", policy_bundle_hash) + + if not isinstance(build_provenance, dict): + raise ValueError("build_provenance must be an object") + unknown_build_fields = build_provenance.keys() - _BUILD_FIELDS + if unknown_build_fields: + raise ValueError( + f"build_provenance contains unsupported fields: {sorted(unknown_build_fields)}" + ) + slsa_level = build_provenance.get("slsa_level") + if isinstance(slsa_level, bool) or slsa_level not in {0, 1, 2, 3}: + raise ValueError("build_provenance.slsa_level must be 0, 1, 2, or 3") + _validate_digest("build_provenance.digest", build_provenance.get("digest")) + for field in ("builder", "provenance_uri"): + if field in build_provenance: + _validate_uri(f"build_provenance.{field}", build_provenance[field]) + + _validate_uri("appraisal_verifier", appraisal_verifier) + + +def _validate_digest(field: str, value: Any) -> None: + if not isinstance(value, str) or not _DIGEST_RE.fullmatch(value): + raise ValueError(f"{field} must be sha256:<64hex> or sha384:<96hex>") + + +def _validate_uri(field: str, value: Any) -> None: + if not isinstance(value, str) or not value: + raise ValueError(f"{field} must be a non-empty URI") + parsed = urlparse(value) + if not parsed.scheme or (parsed.scheme in {"http", "https"} and not parsed.netloc): + raise ValueError(f"{field} must be an absolute URI") diff --git a/integrations/ramen-ai-cmcp/tests/test_mapping.py b/integrations/ramen-ai-cmcp/tests/test_mapping.py index 26cc8f2..eddf537 100644 --- a/integrations/ramen-ai-cmcp/tests/test_mapping.py +++ b/integrations/ramen-ai-cmcp/tests/test_mapping.py @@ -1,116 +1,272 @@ -"""ramen-ai TRACE record mapping tests. +"""Tests for V5 receipt verification and TRACE v0.2 record export.""" -Verifies that build_trace_record correctly maps the committed V5 fixture -receipt onto TRACE Trust Record fields, and that the sign/verify round-trip -and tamper probe work as expected. - -No network access or credentials required. -""" from __future__ import annotations +import hashlib import json +import time from pathlib import Path import agentrust_trace import pytest - -from ramen_ai_trace import build_trace_record, _validate_receipt +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, +) +from trace_tests.result import Status +from trace_tests.runner import run as run_trace_tests + +from _receipt_verify import CONFORMANCE_PUBLIC_KEYS, verify_v5_receipt +from ramen_ai_trace import ( + EAT_PROFILE, + SOFTWARE_MEASUREMENT, + _validate_receipt, + build_trace_record, +) FIXTURES = Path(__file__).resolve().parents[1] / "examples" / "fixtures" -BUNDLE = FIXTURES / "vector1_allowed.json" -IAT = 1_800_000_000 +POLICY_BUNDLE_HASH = "sha256:" + hashlib.sha256(b"test-policy-bundle").hexdigest() +BUILD_DIGEST = "sha256:" + hashlib.sha256(b"ramen-cmcp-adapter-test-artifact").hexdigest() +MODEL = {"provider": "test-provider", "model_id": "test-evaluator", "version": "1"} +BUILD_PROVENANCE = { + "slsa_level": 0, + "builder": "https://github.com/ramen-ai-dev/ramen-ai-integrations", + "digest": BUILD_DIGEST, +} +APPRAISAL_VERIFIER = "https://ramenai.dev/trace/software-only" def _load(name: str) -> dict: return json.loads((FIXTURES / name).read_text(encoding="utf-8")) -def _dummy_jwk() -> dict: - return {"kty": "OKP", "crv": "Ed25519", "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="} - - -@pytest.fixture(scope="module") -def key(): - return agentrust_trace.generate_key() - - -@pytest.fixture(scope="module") -def record(): - f = _load("vector1_allowed.json") - return build_trace_record(f["receipt"], iat=IAT, jwk=_dummy_jwk()) - - -# ── field mapping ─────────────────────────────────────────────────────────── - -def test_eat_profile(record): - assert record["eat_profile"] == "tag:agentrust-io.com,2026:trace-v0.2" - - -def test_subject_is_spiffe_uri(record): - assert record["subject"].startswith("spiffe://ramenai.dev/evaluation/") - - -def test_policy_bundle_hash_has_sha256_prefix(record): - assert record["policy"]["bundle_hash"].startswith("sha256:") - - -def test_policy_enforcement_mode(record): - assert record["policy"]["enforcement_mode"] == "enforce" - - -def test_runtime_platform(record): - assert record["runtime"]["platform"] == "software-only" - - -def test_tool_transcript_call_count(record): - assert record["tool_transcript"]["call_count"] == 1 - - -def test_tool_transcript_hash_has_sha256_prefix(record): - assert record["tool_transcript"]["hash"].startswith("sha256:") - - -def test_appraisal_status_affirming_for_allowed(record): - assert record["appraisal"]["status"] == "affirming" - - -def test_no_cmcp_envelope_markers(record): - assert not {"signature", "trace", "gateway"} & record.keys() - - -def test_fields_map_from_fixture(record): - f = _load("vector1_allowed.json") - payload = json.loads(f["receipt"]["canonical_payload"]) - assert record["policy"]["bundle_hash"] == f"sha256:{payload['payload_hash']}" - assert record["runtime"]["measurement"] == f["receipt"]["id"] - assert record["tool_transcript"]["hash"] == f"sha256:{payload['payload_hash']}" - assert payload["policy_ids"][0] in record["appraisal"]["policy_ref"] - - -# ── sign/verify round-trip ─────────────────────────────────────────────────── - -def test_sign_verify_roundtrip(record): - k = agentrust_trace.generate_key() - signed = agentrust_trace.sign_record(dict(record), k) - agentrust_trace.verify_record(signed, allow_embedded_key=True, max_age_seconds=None) - - -def test_tampered_record_fails_verification(record): - k = agentrust_trace.generate_key() - signed = agentrust_trace.sign_record(dict(record), k) - signed["policy"]["bundle_hash"] = "sha256:" + "f" * 64 - with pytest.raises(Exception): - agentrust_trace.verify_record(signed, allow_embedded_key=True, max_age_seconds=None) - - -# ── _validate_receipt guards ───────────────────────────────────────────────── - -def test_missing_field_raises(): - with pytest.raises(ValueError, match="missing required fields"): - _validate_receipt({"id": "x", "schema_version": "5.0", "kid": "k"}) - - -def test_wrong_schema_version_raises(): - f = _load("vector1_allowed.json") - with pytest.raises(ValueError, match="schema_version"): - _validate_receipt(dict(f["receipt"], schema_version="4.0")) +def _private_key_pem(key: object) -> str: + return key.private_bytes( # type: ignore[union-attr] + encoding=Encoding.PEM, + format=PrivateFormat.PKCS8, + encryption_algorithm=NoEncryption(), + ).decode("ascii") + + +@pytest.fixture +def trace_key(monkeypatch: pytest.MonkeyPatch): + key = agentrust_trace.generate_key() + monkeypatch.setenv("TRACE_PRIVATE_KEY_PEM", _private_key_pem(key)) + return key + + +def _build(fixture: dict, *, iat: int) -> dict: + return build_trace_record( + fixture["receipt"], + original_input=fixture["input"], + iat=iat, + model=MODEL, + data_class="internal", + policy_bundle_hash=POLICY_BUNDLE_HASH, + build_provenance=BUILD_PROVENANCE, + appraisal_verifier=APPRAISAL_VERIFIER, + receipt_public_keys=CONFORMANCE_PUBLIC_KEYS, + ) + + +class TestVerifyV5Receipt: + def test_conformance_key_is_not_trusted_by_default(self): + fixture = _load("vector1_allowed.json") + valid, reason = verify_v5_receipt(fixture["receipt"], fixture["input"]) + assert valid is False + assert "Unknown kid" in (reason or "") + + def test_vector1_allowed_valid_with_explicit_conformance_key(self): + fixture = _load("vector1_allowed.json") + assert verify_v5_receipt( + fixture["receipt"], + fixture["input"], + extra_keys=CONFORMANCE_PUBLIC_KEYS, + ) == (True, None) + + def test_vector2_blocked_valid_with_explicit_conformance_key(self): + fixture = _load("vector2_blocked.json") + assert verify_v5_receipt( + fixture["receipt"], + fixture["input"], + extra_keys=CONFORMANCE_PUBLIC_KEYS, + ) == (True, None) + + def test_payload_hashes_bind_original_inputs(self): + for name in ("vector1_allowed.json", "vector2_blocked.json"): + fixture = _load(name) + payload = json.loads(fixture["receipt"]["canonical_payload"]) + assert payload["payload_hash"] == hashlib.sha256( + fixture["input"].encode("utf-8") + ).hexdigest() + + def test_tampered_signature_fails(self): + fixture = _load("vector_n1_bad_sig.json") + valid, reason = verify_v5_receipt( + fixture["receipt"], + fixture["input"], + extra_keys=CONFORMANCE_PUBLIC_KEYS, + ) + assert valid is False + assert "signature" in (reason or "").lower() + + def test_wrong_input_fails_binding(self): + fixture = _load("vector1_allowed.json") + valid, reason = verify_v5_receipt( + fixture["receipt"], + fixture["input"] + " ", + extra_keys=CONFORMANCE_PUBLIC_KEYS, + ) + assert valid is False + assert "payload_hash" in (reason or "") + + def test_unknown_kid_fails(self): + fixture = _load("vector1_allowed.json") + receipt = dict(fixture["receipt"], kid="ramen_pk_unknown_xyz") + valid, reason = verify_v5_receipt( + receipt, + fixture["input"], + extra_keys=CONFORMANCE_PUBLIC_KEYS, + ) + assert valid is False + assert "Unknown kid" in (reason or "") + + +class TestBuildTraceRecord: + @pytest.fixture + def allowed_record(self, trace_key): + return _build(_load("vector1_allowed.json"), iat=1_800_000_000) + + @pytest.fixture + def blocked_record(self, trace_key): + return _build(_load("vector2_blocked.json"), iat=1_800_000_001) + + def test_v02_profile_and_complete_required_claims(self, allowed_record): + assert allowed_record["eat_profile"] == EAT_PROFILE + assert allowed_record["model"] == MODEL + assert allowed_record["data_class"] == "internal" + assert allowed_record["build_provenance"] == BUILD_PROVENANCE + assert allowed_record["policy"]["bundle_hash"] == POLICY_BUNDLE_HASH + + def test_strict_software_only_semantics(self, allowed_record): + assert allowed_record["runtime"] == { + "platform": "software-only", + "measurement": SOFTWARE_MEASUREMENT, + } + assert allowed_record["appraisal"] == { + "status": "none", + "verifier": APPRAISAL_VERIFIER, + "timestamp": 1_800_000_000, + } + assert "transparency" not in allowed_record + + def test_subject_binds_receipt_without_false_transcript_claim(self, allowed_record): + receipt = _load("vector1_allowed.json")["receipt"] + assert allowed_record["subject"] == ( + f"spiffe://ramenai.dev/evaluation/{receipt['id']}" + ) + assert "tool_transcript" not in allowed_record + + def test_native_signature_uses_dedicated_trace_key(self, allowed_record, trace_key): + expected_jwk = agentrust_trace.key_to_jwk(trace_key) + assert allowed_record["cnf"]["jwk"] == expected_jwk + assert allowed_record["signature"] + assert "=" not in allowed_record["signature"] + agentrust_trace.verify_record(allowed_record, expected_jwk, max_age_seconds=None) + + def test_blocked_receipt_does_not_claim_hardware_appraisal(self, blocked_record): + assert blocked_record["appraisal"]["status"] == "none" + assert blocked_record["runtime"]["platform"] == "software-only" + + def test_forged_receipt_is_never_trace_signed(self, trace_key): + with pytest.raises(ValueError, match="V5 receipt verification failed"): + _build(_load("vector_n1_bad_sig.json"), iat=1_800_000_000) + + def test_wrong_original_input_is_never_trace_signed(self, trace_key): + fixture = _load("vector1_allowed.json") + fixture["input"] += " " + with pytest.raises(ValueError, match="V5 receipt verification failed"): + _build(fixture, iat=1_800_000_000) + + def test_missing_trace_private_key_fails_closed(self, monkeypatch): + monkeypatch.delenv("TRACE_PRIVATE_KEY_PEM", raising=False) + with pytest.raises(RuntimeError, match="TRACE_PRIVATE_KEY_PEM is required"): + _build(_load("vector1_allowed.json"), iat=1_800_000_000) + + def test_invalid_trace_private_key_is_rejected(self, monkeypatch): + monkeypatch.setenv( + "TRACE_PRIVATE_KEY_PEM", + "-----BEGIN PRIVATE KEY-----\ninvalid\n-----END PRIVATE KEY-----", + ) + with pytest.raises(ValueError, match="not a valid private key"): + _build(_load("vector1_allowed.json"), iat=1_800_000_000) + + def test_required_caller_evidence_is_validated(self, trace_key): + fixture = _load("vector1_allowed.json") + with pytest.raises(ValueError, match="policy_bundle_hash"): + build_trace_record( + fixture["receipt"], + original_input=fixture["input"], + iat=1_800_000_000, + model=MODEL, + data_class="internal", + policy_bundle_hash="not-a-digest", + build_provenance=BUILD_PROVENANCE, + appraisal_verifier=APPRAISAL_VERIFIER, + receipt_public_keys=CONFORMANCE_PUBLIC_KEYS, + ) + + def test_receipt_identity_must_match_signed_payload(self, trace_key): + fixture = _load("vector1_allowed.json") + fixture["receipt"] = dict( + fixture["receipt"], id="00000000-0000-0000-0000-000000000000" + ) + with pytest.raises(ValueError, match="does not match canonical_payload"): + _build(fixture, iat=1_800_000_000) + + def test_missing_receipt_field_raises(self): + with pytest.raises(ValueError, match="missing required fields"): + _validate_receipt({"id": "x", "schema_version": "5.0", "kid": "k"}) + + def test_wrong_receipt_schema_version_raises(self): + fixture = _load("vector1_allowed.json") + receipt = dict(fixture["receipt"], schema_version="4.0") + with pytest.raises(ValueError, match="schema_version"): + _validate_receipt(receipt) + + def test_invalid_canonical_payload_json_raises(self): + fixture = _load("vector1_allowed.json") + receipt = dict(fixture["receipt"], canonical_payload="{not json}") + with pytest.raises(ValueError, match="canonical_payload"): + _validate_receipt(receipt) + + +class TestTraceConformance: + def test_signed_software_record_passes_level_0(self, trace_key): + record = _build(_load("vector1_allowed.json"), iat=int(time.time())) + results = run_trace_tests(record, "trace", level=0) + findings = [finding for group in results.values() for finding in group] + assert len(findings) == 8 + assert all(finding.status is Status.PASS for finding in findings) + assert [ + finding.status for finding in findings if finding.code == "TR-SIG-005" + ] == [Status.PASS] + + def test_signed_software_record_fails_only_level_1_runtime_rule(self, trace_key): + record = _build(_load("vector1_allowed.json"), iat=int(time.time())) + results = run_trace_tests(record, "trace", level=1) + failures = [ + finding + for findings in results.values() + for finding in findings + if finding.status is Status.FAIL + ] + assert [(finding.code, finding.message) for finding in failures] == [ + ( + "TR-RTE-001", + "TR-RTE-001: runtime.platform 'software-only' is development-mode " + "and not acceptable for hardware-attested levels (Level 1 requires " + "a hardware TEE platform)", + ) + ] diff --git a/schema/integration.schema.json b/schema/integration.schema.json index 1ddb5c2..782a5e9 100644 --- a/schema/integration.schema.json +++ b/schema/integration.schema.json @@ -82,6 +82,7 @@ "properties": { "cmcp-runtime": { "type": "string" }, "agentrust-trace": { "type": "string" }, + "agentrust-trace-tests": { "type": "string" }, "agent-manifest": { "type": "string" } }, "description": "Released package versions the integration was last tested against."