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
29 changes: 28 additions & 1 deletion schema/trace-claim-v0.3-draft.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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})$",
Comment thread
imran-siddique marked this conversation as resolved.
"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.",
Expand Down
38 changes: 24 additions & 14 deletions scripts/gen_v03_draft_schema.py
Original file line number Diff line number Diff line change
@@ -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]
"""
Expand Down Expand Up @@ -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

Expand Down
188 changes: 182 additions & 6 deletions tests/test_v03_draft_schema.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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():
Expand Down Expand Up @@ -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


Expand All @@ -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"))
Loading