Skip to content
Open
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
7 changes: 4 additions & 3 deletions docs/rfcs/runtime-evidence-profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
**Status:** Draft proposal. Binds nothing.
**Scope:** A `runtime.evidence` member, the rules for checking it, and the grades a verifier may report. Additive; every v0.2 record stays valid.
**Target:** `spec/trace-v0.2.md` §3.1 and §5, for v0.3.
**Conformance material:** [`examples/runtime-evidence/`](https://github.com/agentrust-io/trace-spec/tree/main/examples/runtime-evidence): 13 vectors, generator, and reference rules, built on a genuine Intel TDX quote rather than a minted one.
**Conformance material:** [`examples/runtime-evidence/`](https://github.com/agentrust-io/trace-spec/tree/main/examples/runtime-evidence): 14 vectors, generator, and reference rules, built on a genuine Intel TDX quote rather than a minted one.
**Draft schema:** [`schema/trace-claim-v0.3-draft.json`](../../schema/trace-claim-v0.3-draft.json), generated from `schema/trace-claim.json` with two deliberate boundaries: the v0.3 profile URI and the new `runtime.evidence` member.

Requirement keywords are lowercase throughout, deliberately, on the line `CONTRIBUTING.md` draws: normative text lives in `spec/`, informative text binds no implementation. If these rules are adopted they become uppercase there and this file becomes a pointer to where they went. A proposal that writes itself in the imperative is a specification nobody agreed to.
Expand Down Expand Up @@ -197,13 +197,14 @@ reasons.
| `reject-platform-not-the-evidence` | reject, the platform is not what this evidence roots | not graded |
| `advisory-binds-cannot-raise-a-claim` | `platform-attested` | self-reported |
| `commitment-cannot-attest-model` | `platform-attested` | self-reported |
| `context-embedded-key-not-trusted` | `platform-attested` | self-reported; signer trust `not-established` |

The run closes with `13/13 vectors behaved as the profile says they must (1 of them documenting a limit
The run closes with `14/14 vectors behaved as the profile says they must (1 of them documenting a limit
of the rules rather than a success).`

Each vector asserts both the record grade and the model-claim grade, because §6.1 is a claim about the relationship between the two and a corpus that checked only the first would not test it.

The dedicated `runtime-evidence` CI job runs these rules with the external verifier pinned to `agent-manifest` commit `934809709a2815695d65cfacb45dc0a164286046`. It checks the committed grades and the specific reason for each rejection, then regenerates all 13 vectors and compares their bytes. Missing verifier code or missing captures fail that job rather than skipping it. The ordinary TRACE suite checks the schema, the record signatures and the evidence shapes independently, and does not depend on `agent-manifest`.
The dedicated `runtime-evidence` CI job runs these rules with the external verifier pinned to `agent-manifest` commit `934809709a2815695d65cfacb45dc0a164286046`. It checks the committed grades and the specific reason for each rejection, then regenerates all 14 vectors and compares their bytes. Missing verifier code or missing captures fail that job rather than skipping it. The ordinary TRACE suite checks the schema, the record signatures and the evidence shapes independently, and does not depend on `agent-manifest`.

### 7.1 What the corpus found

Expand Down
1 change: 1 addition & 0 deletions examples/runtime-evidence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ is what a skipped test would have looked like: coverage that is none.
| `reject-platform-not-the-evidence` | reject | `amd-sev-snp` claimed over a TDX quote |
| `advisory-binds-cannot-raise-a-claim` | `platform-attested` | declares a binding it does not have; model claim stays self-reported |
| `commitment-cannot-attest-model` | `platform-attested` | a recomputable `REPORT_DATA` match is a commitment, not model evidence |
| `context-embedded-key-not-trusted` | `platform-attested` | runtime evidence can be valid while external signer trust remains `not-established` |

Each vector asserts the record grade **and** the model-claim grade. The profile's §6.1
is a claim about how those two relate, so a corpus checking only the first would not
Expand Down
60 changes: 48 additions & 12 deletions examples/runtime-evidence/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,8 @@ def base_record(quote: bytes, key: Ed25519PrivateKey) -> dict:
)


def build_corpus() -> list[tuple[str, str, str | None, dict]]:
"""Return (name, expected grade, expected model claim or None, record)."""
def build_corpus() -> list[dict]:
"""Return data-driven vector objects, including verifier context when needed."""
key = Ed25519PrivateKey.from_private_bytes(PUBLISHED_TEST_KEY)
quote_a = (HARDWARE / "tdx_quote.bin").read_bytes()
quote_b = (HARDWARE / "tdx_quote_manifest.bin").read_bytes()
Expand Down Expand Up @@ -438,7 +438,41 @@ def build_corpus() -> list[tuple[str, str, str | None, dict]]:
)
)

return vectors
# Normalize the legacy tuple construction above into one data model. Context and
# extra expectations belong to the vector, not to filename-specific generator code.
corpus = [
{
"name": name,
"expected": {"grade": grade, "model_claim": model_claim},
"record": record,
}
for name, grade, model_claim, record in vectors
]

# A valid embedded signing key proves internal signature consistency, not relying-
# party trust. Give the verifier a real but different external trust root so this
# case is discriminating: it would fail if an implementation silently promoted the
# embedded key into the configured trust set.
trusted_context_key = Ed25519PrivateKey.from_private_bytes(
bytes.fromhex("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb")
)
trusted_context_record = base_record(quote_a, trusted_context_key)
corpus.append(
{
"name": "context-embedded-key-not-trusted",
"expected": {
"grade": "platform-attested",
"model_claim": "model claim: self-reported",
"signer_trust": "not-established",
},
"context": {
"trusted_root_keys": [trusted_context_record["cnf"]["jwk"]],
},
"record": copy.deepcopy(accept),
}
)

return corpus


def main() -> int:
Expand All @@ -447,7 +481,11 @@ def main() -> int:
args = ap.parse_args()

rows: list[tuple[str, str, str, bool, str]] = []
for name, expected, expected_claim, record in build_corpus():
for vector in build_corpus():
name = vector["name"]
expected = vector["expected"]["grade"]
expected_claim = vector["expected"]["model_claim"]
record = vector["record"]
try:
grade = appraise(record)
except Reject as e:
Expand All @@ -464,15 +502,13 @@ def main() -> int:
# without re-deriving it. `record` stays a clean TRACE record, because a
# vector carrying an extra top-level member would fail the very schema the
# corpus exists to exercise.
wrapper = {
key: value
for key, value in vector.items()
if key != "name"
}
(out / f"{name}.json").write_text(
json.dumps(
{
"expected": {"grade": expected, "model_claim": expected_claim},
"record": record,
},
indent=2,
)
+ "\n",
json.dumps(wrapper, indent=2) + "\n",
encoding="utf-8",
)

Expand Down
32 changes: 32 additions & 0 deletions examples/runtime-evidence/test_appraisal.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import sys

import pytest
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

import generate as rules
Expand Down Expand Up @@ -93,3 +94,34 @@ def test_regeneration_matches_committed_vectors(tmp_path: Path) -> None:
assert {path.name for path in generated} == {path.name for path in VECTORS}
for path in generated:
assert path.read_bytes() == (VECTOR_DIR / path.name).read_bytes(), path.name


def _jwk_identity(jwk: dict) -> tuple[object, object, object]:
return (jwk.get("kty"), jwk.get("crv"), jwk.get("x"))


def test_embedded_signer_is_not_established_by_external_context() -> None:
vector = json.loads(
(VECTOR_DIR / "context-embedded-key-not-trusted.json").read_text(encoding="utf-8")
)
record = vector["record"]
rules.check_envelope(record)
assert rules.appraise(record) == "platform-attested"

external_keys = vector["context"]["trusted_root_keys"]
assert external_keys, "the trust context must be non-empty or this case is vacuous"

embedded = _jwk_identity(record["cnf"]["jwk"])
configured = {_jwk_identity(jwk) for jwk in external_keys}
assert embedded not in configured

# The distinction is cryptographic, not just metadata: the record verifies under
# its embedded key, while the relying party's configured trusted key does not
# authenticate this signature.
signature = rules.unb64u(record["signature"])
body = rules._canonical_bytes({k: v for k, v in record.items() if k != "signature"})
for trusted_jwk in external_keys:
with pytest.raises(InvalidSignature):
rules._pubkey_from_jwk(trusted_jwk).verify(signature, body)

assert vector["expected"]["signer_trust"] == "not-established"
Loading
Loading