diff --git a/schema/trace-claim-v0.3-draft.json b/schema/trace-claim-v0.3-draft.json index 24d73cb4..deca641c 100644 --- a/schema/trace-claim-v0.3-draft.json +++ b/schema/trace-claim-v0.3-draft.json @@ -33,7 +33,7 @@ } }, "title": "TRACE Trust Record (v0.3 runtime-evidence draft)", - "description": "DRAFT. v0.3 profile boundary plus runtime.evidence. Binds nothing until docs/rfcs/runtime-evidence-profile.md is adopted. Outside the profile identifier and runtime.evidence, members are byte-identical to schema/trace-claim.json so the diff is reviewable.", + "description": "DRAFT. v0.3 profile boundary plus runtime.evidence and manifest. Binds nothing until docs/rfcs/runtime-evidence-profile.md and #483 are adopted. Outside the profile identifier, runtime.evidence, and manifest, members are byte-identical to schema/trace-claim.json so the diff is reviewable.", "type": "object", "required": [ "eat_profile", @@ -876,6 +876,33 @@ }, "additionalProperties": false }, + "manifest": { + "type": "object", + "description": "The agent manifest this execution ran under, by signed content-addressed reference (#483). Reference-only: the record names the manifest and commits to its digest under the record signature, leaving appraisal to the verifier. A producer-asserted verification result is not carried here.", + "required": [ + "id", + "digest", + "media_type" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Identifier of the agent manifest this execution ran under." + }, + "digest": { + "type": "string", + "pattern": "^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$", + "description": "SHA-256 or SHA-384 digest of the manifest bytes this execution ran under, covered by the record signature and the target of runtime.evidence.binds when set to manifest-digest." + }, + "media_type": { + "type": "string", + "minLength": 1, + "description": "Media type of the referenced manifest bytes (for example application/json or application/agent-manifest+cose)." + } + }, + "additionalProperties": false + }, "signature": { "type": "string", "description": "OPTIONAL embedded signature: base64url (no padding) signature by the cnf key over the canonical JSON form of the record with only this field absent. Every Trust Record MUST be signature-bound per spec section 3.2.2, but enveloped profiles (e.g. JWS, cMCP RuntimeClaim) carry the signature outside the record, so this field is not required by the schema. An 86-character value is the base64url encoding of a 64-byte signature (Ed25519 or ES256) and MUST be canonically encoded per RFC 4648 section 3.5: its final character's unused bits MUST be zero, so it MUST end in A, Q, g, or w.", diff --git a/scripts/gen_v03_draft_schema.py b/scripts/gen_v03_draft_schema.py index e1905d87..fd2df048 100644 --- a/scripts/gen_v03_draft_schema.py +++ b/scripts/gen_v03_draft_schema.py @@ -1,15 +1,17 @@ """Regenerate schema/trace-claim-v0.3-draft.json from schema/trace-claim.json. -The draft declares that, outside the profile identifier and ``runtime.evidence``, -its members are byte-identical to the canonical schema. Nothing enforced that, -so it was forked once and drifted: it never received the private-key refusal in -``cnf.jwk`` or the RSA branch (#368). This script is now the only way the draft -changes outside its own members, and tests/test_v03_draft_schema.py fails when -the committed file is not what it produces. +The draft declares that, outside the profile identifier, ``runtime.evidence``, +and ``manifest``, its members are byte-identical to the canonical schema. +Nothing enforced that, so it was forked once and drifted: it never received the +private-key refusal in ``cnf.jwk`` or the RSA branch (#368). This script is now +the only way the draft changes outside its own members, and +tests/test_v03_draft_schema.py fails when the committed file is not what it +produces. The draft's own members, taken from the committed draft: -``$id``, ``title``, ``description``, ``properties.eat_profile`` and -``properties.runtime.properties.evidence``. Everything else comes from canonical. +``$id``, ``title``, ``description``, ``properties.eat_profile``, +``properties.runtime.properties.evidence``, and ``properties.manifest``. +Everything else comes from canonical. Usage: python scripts/gen_v03_draft_schema.py [--check] """ @@ -47,19 +49,27 @@ def _insert_after( return out +def _copy_member( + target: dict[str, Any], source: dict[str, Any], key: str +) -> dict[str, Any]: + keys = list(source) + position = keys.index(key) + after = keys[position - 1] if position > 0 else None + return _insert_after(target, key, copy.deepcopy(source[key]), after) + + def build(canonical: dict[str, Any], draft: dict[str, Any]) -> dict[str, Any]: result = copy.deepcopy(canonical) for key in DRAFT_OWN_TOP_LEVEL: result[key] = draft[key] result["properties"]["eat_profile"] = copy.deepcopy(draft["properties"]["eat_profile"]) - draft_runtime = draft["properties"]["runtime"]["properties"] - keys = list(draft_runtime) - position = keys.index("evidence") - after = keys[position - 1] if position > 0 else None runtime = result["properties"]["runtime"] - runtime["properties"] = _insert_after( - runtime["properties"], "evidence", copy.deepcopy(draft_runtime["evidence"]), after + runtime["properties"] = _copy_member( + runtime["properties"], draft["properties"]["runtime"]["properties"], "evidence" + ) + result["properties"] = _copy_member( + result["properties"], draft["properties"], "manifest" ) return result diff --git a/tests/test_v03_draft_schema.py b/tests/test_v03_draft_schema.py index 2aa3a74d..39fdd523 100644 --- a/tests/test_v03_draft_schema.py +++ b/tests/test_v03_draft_schema.py @@ -1,9 +1,10 @@ -"""The v0.3 draft schema is canonical plus its own declared members, and nothing else (#368). +"""The v0.3 draft schema is canonical plus its own declared members, and nothing else (#368, #483). -The draft states that outside the profile identifier and ``runtime.evidence`` it -is byte-identical to schema/trace-claim.json. It was forked once and drifted, -missing the ``cnf.jwk`` private-key refusal (#296) and the RSA branch (#311), so a -v0.3 record carrying the private half of its confirmation key validated against it. +The draft states that outside the profile identifier, ``runtime.evidence``, and +``manifest`` it is byte-identical to schema/trace-claim.json. It was forked once +and drifted, missing the ``cnf.jwk`` private-key refusal (#296) and the RSA +branch (#311), so a v0.3 record carrying the private half of its confirmation +key validated against it. """ from __future__ import annotations @@ -18,6 +19,10 @@ REPO_ROOT = Path(__file__).resolve().parents[1] SCRIPT = REPO_ROOT / "scripts" / "gen_v03_draft_schema.py" +MANIFEST_VECTOR_DIR = ( + REPO_ROOT / "conformance" / "tests" / "vectors" / "manifest-ref-v0.3-draft" +) +V03_PROFILE = "tag:agentrust-io.com,2026:trace-v0.3" def _generator(): @@ -47,6 +52,7 @@ def test_draft_differs_from_canonical_only_in_its_declared_members(): stripped[key] = canonical[key] stripped["properties"]["eat_profile"] = canonical["properties"]["eat_profile"] del stripped["properties"]["runtime"]["properties"]["evidence"] + del stripped["properties"]["manifest"] assert stripped == canonical @@ -55,12 +61,182 @@ def test_draft_refuses_a_private_key_member_as_canonical_does(member): record = _load_example() record["cnf"]["jwk"][member] = "AAAA" v02, v03 = copy.deepcopy(record), copy.deepcopy(record) - v03["eat_profile"] = "tag:agentrust-io.com,2026:trace-v0.3" + v03["eat_profile"] = V03_PROFILE canonical = jsonschema.Draft202012Validator(_load("trace-claim.json")) draft = jsonschema.Draft202012Validator(_load("trace-claim-v0.3-draft.json")) assert list(canonical.iter_errors(v02)) assert list(draft.iter_errors(v03)) +@pytest.mark.parametrize( + "fixture_name", + [ + "01-digest-matches.json", + "02-digest-mismatch.json", + "04-unresolvable.json", + "05-added-after-signing.json", + "06-control-no-manifest.json", + ], +) +def test_draft_accepts_manifest_reference_fixtures(fixture_name: str): + vector = json.loads((MANIFEST_VECTOR_DIR / fixture_name).read_text(encoding="utf-8")) + record = copy.deepcopy(vector["record"]) + canonical = jsonschema.Draft202012Validator(_load("trace-claim.json")) + canonical_errors = list(canonical.iter_errors(record)) + if "manifest" in record: + assert any("manifest" in err.message for err in canonical_errors) + else: + assert not canonical_errors + record["eat_profile"] = V03_PROFILE + draft = jsonschema.Draft202012Validator(_load("trace-claim-v0.3-draft.json")) + assert not list(draft.iter_errors(record)) + + +def test_draft_accepts_sha384_manifest_digest(): + record = _load_v03_manifest_record() + record["manifest"]["digest"] = "sha384:" + "a" * 96 + draft = jsonschema.Draft202012Validator(_load("trace-claim-v0.3-draft.json")) + assert not list(draft.iter_errors(record)) + + +def test_draft_rejects_asserted_manifest_verification_result(): + vector = json.loads( + (MANIFEST_VECTOR_DIR / "03-mismatch-with-asserted-result.json").read_text( + encoding="utf-8" + ) + ) + record = copy.deepcopy(vector["record"]) + record["eat_profile"] = V03_PROFILE + draft = jsonschema.Draft202012Validator(_load("trace-claim-v0.3-draft.json")) + errors = list(draft.iter_errors(record)) + assert any("manifest_verification_result" in err.message for err in errors) + del record["manifest_verification_result"] + assert not list(draft.iter_errors(record)) + + +@pytest.mark.parametrize( + "bad_manifest", + [ + pytest.param("urn:example:agent-manifest:payments-agent", id="non-object"), + pytest.param( + {"digest": "sha256:" + "a" * 64, "media_type": "application/json"}, + id="missing-id", + ), + pytest.param( + {"id": "urn:example:agent-manifest:payments-agent", "media_type": "application/json"}, + id="missing-digest", + ), + pytest.param( + {"id": "urn:example:agent-manifest:payments-agent", "digest": "sha256:" + "a" * 64}, + id="missing-media-type", + ), + pytest.param( + { + "id": 123, + "digest": "sha256:" + "a" * 64, + "media_type": "application/json", + }, + id="non-string-id", + ), + pytest.param( + { + "id": "urn:example:agent-manifest:payments-agent", + "digest": 123, + "media_type": "application/json", + }, + id="non-string-digest", + ), + pytest.param( + { + "id": "urn:example:agent-manifest:payments-agent", + "digest": "sha256:" + "a" * 64, + "media_type": 123, + }, + id="non-string-media-type", + ), + pytest.param( + { + "id": "", + "digest": "sha256:" + "a" * 64, + "media_type": "application/json", + }, + id="empty-id", + ), + pytest.param( + { + "id": "urn:example:agent-manifest:payments-agent", + "digest": "sha256:" + "a" * 64, + "media_type": "", + }, + id="empty-media-type", + ), + pytest.param( + { + "id": "urn:example:agent-manifest:payments-agent", + "digest": "sha256:" + "A" * 64, + "media_type": "application/json", + }, + id="sha256-uppercase", + ), + pytest.param( + { + "id": "urn:example:agent-manifest:payments-agent", + "digest": "sha256:" + "a" * 63, + "media_type": "application/json", + }, + id="sha256-wrong-length", + ), + pytest.param( + { + "id": "urn:example:agent-manifest:payments-agent", + "digest": "sha384:" + "A" * 96, + "media_type": "application/json", + }, + id="sha384-uppercase", + ), + pytest.param( + { + "id": "urn:example:agent-manifest:payments-agent", + "digest": "sha384:" + "a" * 95, + "media_type": "application/json", + }, + id="sha384-wrong-length", + ), + pytest.param( + { + "id": "urn:example:agent-manifest:payments-agent", + "digest": "sha512:" + "a" * 128, + "media_type": "application/json", + }, + id="unsupported-digest-algorithm", + ), + pytest.param( + { + "id": "urn:example:agent-manifest:payments-agent", + "digest": "sha256:" + "a" * 64, + "media_type": "application/json", + "verification_result": "verified", + }, + id="unexpected-property", + ), + ], +) +def test_draft_rejects_malformed_manifest(bad_manifest): + record = _load_v03_manifest_record() + record["manifest"] = bad_manifest + draft = jsonschema.Draft202012Validator(_load("trace-claim-v0.3-draft.json")) + errors = list(draft.iter_errors(record)) + assert any(list(err.absolute_path)[:1] == ["manifest"] for err in errors) + + +def _load_v03_manifest_record() -> dict: + vector = json.loads( + (MANIFEST_VECTOR_DIR / "01-digest-matches.json").read_text(encoding="utf-8") + ) + record = copy.deepcopy(vector["record"]) + record["eat_profile"] = V03_PROFILE + return record + + def _load_example() -> dict: return json.loads((REPO_ROOT / "examples" / "amd-sev-snp.json").read_text(encoding="utf-8"))