From 604a635136b8ed43b501fce088340f5d27a8bf4d Mon Sep 17 00:00:00 2001 From: harshnair75567-cloud Date: Mon, 14 Sep 2026 19:06:49 +0530 Subject: [PATCH 1/2] fix(adapters): validate iat before it reaches the record, not after SandboxSessionResult and AGTSessionResult wrote session.iat straight into build_trust_record's top-level "iat" key with no type check. Every other field reaches the record through a models.py pydantic constructor, which coerces or refuses a bad value before it is dumped -- iat bypassed all of them. TrustRecord.model_validate(), which sign_record's own docstring recommends for structural validity before writing, gave a false pass on a numeric-string iat: pydantic's lax mode coerces "1800000000" -> 1800000000 during validation, but that happens on a new object and never touches the dict being signed. The wire bytes kept the string, which jsonschema.validate against trace-v0.2.json and this library's own verify_record both then reject. AGTSessionResult additionally had no __post_init__ at all. Same failure mode as provenance.build_record's pre-#320 issued_at coercion, fixed there by validating the caller's explicit value before use instead of converting it. This applies the same fix to both adapters, mirroring _check_structure's bound (non-negative, <= JCS_SAFE_INTEGER, bool excluded since bool subclasses int). 1561 passed, 1 skipped. ruff and mypy clean on both changed source files. Signed-off-by: harshnair75567-cloud --- src/agentrust_trace/adapters/agt.py | 13 ++++++++++++- src/agentrust_trace/adapters/sandbox.py | 12 ++++++++++++ tests/test_agt_adapter.py | 24 ++++++++++++++++++++++++ tests/test_sandbox_adapter.py | 19 +++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/agentrust_trace/adapters/agt.py b/src/agentrust_trace/adapters/agt.py index 0a329ad3..e7fc24d3 100644 --- a/src/agentrust_trace/adapters/agt.py +++ b/src/agentrust_trace/adapters/agt.py @@ -13,6 +13,7 @@ import rfc8785 from agentrust_trace.models import ( + JCS_SAFE_INTEGER, Appraisal, BuildProvenance, ModelInfo, @@ -57,7 +58,17 @@ class AGTSessionResult: iat: int = field(default_factory=lambda: int(time.time())) """Issuance timestamp. Defaults to now.""" - + def __post_init__(self) -> None: + if ( + not isinstance(self.iat, int) + or isinstance(self.iat, bool) + or self.iat < 0 + or self.iat > JCS_SAFE_INTEGER + ): + raise ValueError( + f"iat must be a non-negative integer Unix timestamp within the JCS " + f"safe-integer range, got {self.iat!r}" + ) class TraceAGTAdapter: """Build Level 0 TRACE Trust Records from AGT govern() session output. diff --git a/src/agentrust_trace/adapters/sandbox.py b/src/agentrust_trace/adapters/sandbox.py index 9c7afe3c..0af0bea9 100644 --- a/src/agentrust_trace/adapters/sandbox.py +++ b/src/agentrust_trace/adapters/sandbox.py @@ -80,6 +80,7 @@ import rfc8785 from agentrust_trace.models import ( + JCS_SAFE_INTEGER, Appraisal, BuildProvenance, ModelInfo, @@ -227,9 +228,20 @@ def __post_init__(self) -> None: if not _DIGEST_RE.match(self.image_digest): raise ValueError( f"image_digest {self.image_digest!r} must be a sha256: or sha384: digest." + ) + if ( + not isinstance(self.iat, int) + or isinstance(self.iat, bool) + or self.iat < 0 + or self.iat > JCS_SAFE_INTEGER + ): + raise ValueError( + f"iat must be a non-negative integer Unix timestamp within the JCS " + f"safe-integer range, got {self.iat!r}" ) + class TraceSandboxAdapter: """Build Trust Records from a sandboxed agent runtime's session output. diff --git a/tests/test_agt_adapter.py b/tests/test_agt_adapter.py index 4538a6ed..bc1a47d4 100644 --- a/tests/test_agt_adapter.py +++ b/tests/test_agt_adapter.py @@ -286,3 +286,27 @@ def test_an_unappraised_record_still_signs_and_verifies() -> None: signed = sign_record(record, key) assert verify_record(signed, public_key_or_jwk=key_to_jwk(key)) is not None assert signed["appraisal"]["status"] == "none" + + +# --------------------------------------------------------------------------- +# iat reaches the record untouched (same failure mode as #320) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("iat", ["1800000000", True, False, -5, 1.5, 2**60]) +def test_iat_must_be_a_bounded_non_negative_integer(iat) -> None: + """Every other field here reaches the record through a models.py pydantic + constructor, which coerces or refuses a bad type before it is dumped. `iat` + used to reach build_trust_record's top-level "iat" key untouched, with + AGTSessionResult performing no validation of any field at all: a numeric + string or a bool survived to the signed wire form, and TrustRecord.model_validate() + reported it valid (pydantic's lax mode coerces on the way in) while the wire + bytes stayed the original, schema-invalid type. Same failure mode as + provenance.build_record's pre-#320 issued_at coercion.""" + with pytest.raises(ValueError, match="iat must be a non-negative integer"): + _make_session(iat=iat) + + +def test_valid_iat_round_trips_as_an_int_on_the_wire() -> None: + record = _make_adapter().build_trust_record(_make_session(iat=1800000000)) + assert record["iat"] == 1800000000 + assert isinstance(record["iat"], int) diff --git a/tests/test_sandbox_adapter.py b/tests/test_sandbox_adapter.py index 85d75ba1..4cc118bf 100644 --- a/tests/test_sandbox_adapter.py +++ b/tests/test_sandbox_adapter.py @@ -319,6 +319,25 @@ def test_a_bad_sandbox_id_fails_at_the_adapter_not_at_model_validate() -> None: _make_session(sandbox_id="build-7f2a") +@pytest.mark.parametrize("iat", ["1800000000", True, False, -5, 1.5, 2**60]) +def test_iat_must_be_a_bounded_non_negative_integer(iat) -> None: + """Every other field here reaches the record through a models.py pydantic + constructor, which coerces or refuses a bad type before it is dumped. `iat` + used to reach build_trust_record's top-level "iat" key untouched, so a + numeric string or a bool survived to the signed wire form: model_validate() + reported it valid (pydantic's lax mode coerces on the way in) while the wire + bytes stayed the original, schema-invalid type. Same failure mode as + provenance.build_record's pre-#320 issued_at coercion.""" + with pytest.raises(ValueError, match="iat must be a non-negative integer"): + _make_session(iat=iat) + + +def test_valid_iat_round_trips_as_an_int_on_the_wire() -> None: + record = _make_adapter().build_trust_record(_make_session(iat=1800000000)) + assert record["iat"] == 1800000000 + assert isinstance(record["iat"], int) + + # --------------------------------------------------------------------------- # 6. Transcript hashing uses JCS # --------------------------------------------------------------------------- From f39d0c5fedd553fd3e1ae41400ac42c6d2688b90 Mon Sep 17 00:00:00 2001 From: harshnair75567-cloud Date: Wed, 16 Sep 2026 19:48:16 +0530 Subject: [PATCH 2/2] Fix iat lower-bound bypass in sandbox/agt adapters, add boundary tests --- src/agentrust_trace/adapters/agt.py | 12 +++++++++--- src/agentrust_trace/adapters/sandbox.py | 12 ++++++++---- tests/test_agt_adapter.py | 17 +++++++++++++++-- tests/test_sandbox_adapter.py | 16 +++++++++++++--- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/agentrust_trace/adapters/agt.py b/src/agentrust_trace/adapters/agt.py index e7fc24d3..78b2facf 100644 --- a/src/agentrust_trace/adapters/agt.py +++ b/src/agentrust_trace/adapters/agt.py @@ -22,6 +22,11 @@ ToolTranscript, ) +# TrustRecord.iat is Field(ge=1700000000) in models.py, matching "minimum" in +# schema/trace-v0.2.json. Below it a record is schema-invalid, which is the whole +# failure this check exists to stop -- so the floor is the contract's, not zero. +TRACE_MIN_IAT = 1700000000 + @dataclass class AGTSessionResult: @@ -58,16 +63,17 @@ class AGTSessionResult: iat: int = field(default_factory=lambda: int(time.time())) """Issuance timestamp. Defaults to now.""" + def __post_init__(self) -> None: if ( not isinstance(self.iat, int) or isinstance(self.iat, bool) - or self.iat < 0 + or self.iat < TRACE_MIN_IAT or self.iat > JCS_SAFE_INTEGER ): raise ValueError( - f"iat must be a non-negative integer Unix timestamp within the JCS " - f"safe-integer range, got {self.iat!r}" + f"iat must be an integer Unix timestamp within the TRACE v0.2 range " + f"[{TRACE_MIN_IAT}, {JCS_SAFE_INTEGER}], got {self.iat!r}" ) class TraceAGTAdapter: diff --git a/src/agentrust_trace/adapters/sandbox.py b/src/agentrust_trace/adapters/sandbox.py index 0af0bea9..f9535fec 100644 --- a/src/agentrust_trace/adapters/sandbox.py +++ b/src/agentrust_trace/adapters/sandbox.py @@ -88,6 +88,10 @@ RuntimeInfo, ToolTranscript, ) +# TrustRecord.iat is Field(ge=1700000000) in models.py, matching "minimum" in +# schema/trace-v0.2.json. Below it a record is schema-invalid, which is the whole +# failure this check exists to stop -- so the floor is the contract's, not zero. +TRACE_MIN_IAT = 1700000000 __all__ = ["SandboxAttestation", "SandboxSessionResult", "TraceSandboxAdapter"] @@ -228,16 +232,16 @@ def __post_init__(self) -> None: if not _DIGEST_RE.match(self.image_digest): raise ValueError( f"image_digest {self.image_digest!r} must be a sha256: or sha384: digest." - ) + ) if ( not isinstance(self.iat, int) or isinstance(self.iat, bool) - or self.iat < 0 + or self.iat < TRACE_MIN_IAT or self.iat > JCS_SAFE_INTEGER ): raise ValueError( - f"iat must be a non-negative integer Unix timestamp within the JCS " - f"safe-integer range, got {self.iat!r}" + f"iat must be an integer Unix timestamp within the TRACE v0.2 range " + f"[{TRACE_MIN_IAT}, {JCS_SAFE_INTEGER}], got {self.iat!r}" ) diff --git a/tests/test_agt_adapter.py b/tests/test_agt_adapter.py index bc1a47d4..1a4b19fb 100644 --- a/tests/test_agt_adapter.py +++ b/tests/test_agt_adapter.py @@ -10,6 +10,7 @@ from agentrust_trace import TrustRecord, sign_record, generate_key, key_to_jwk, verify_record from agentrust_trace.adapters import AGTSessionResult, TraceAGTAdapter +from agentrust_trace.models import JCS_SAFE_INTEGER # --------------------------------------------------------------------------- # Fixtures @@ -292,7 +293,10 @@ def test_an_unappraised_record_still_signs_and_verifies() -> None: # iat reaches the record untouched (same failure mode as #320) # --------------------------------------------------------------------------- -@pytest.mark.parametrize("iat", ["1800000000", True, False, -5, 1.5, 2**60]) +@pytest.mark.parametrize( + "iat", + ["1800000000", True, False, -5, 0, 1, 1699999999, 1.5, 2**60, JCS_SAFE_INTEGER + 1], +) def test_iat_must_be_a_bounded_non_negative_integer(iat) -> None: """Every other field here reaches the record through a models.py pydantic constructor, which coerces or refuses a bad type before it is dumped. `iat` @@ -302,7 +306,9 @@ def test_iat_must_be_a_bounded_non_negative_integer(iat) -> None: reported it valid (pydantic's lax mode coerces on the way in) while the wire bytes stayed the original, schema-invalid type. Same failure mode as provenance.build_record's pre-#320 issued_at coercion.""" - with pytest.raises(ValueError, match="iat must be a non-negative integer"): + with pytest.raises( + ValueError, match="iat must be an integer Unix timestamp within the TRACE v0.2 range" + ): _make_session(iat=iat) @@ -310,3 +316,10 @@ def test_valid_iat_round_trips_as_an_int_on_the_wire() -> None: record = _make_adapter().build_trust_record(_make_session(iat=1800000000)) assert record["iat"] == 1800000000 assert isinstance(record["iat"], int) + + +@pytest.mark.parametrize("iat", [1700000000, JCS_SAFE_INTEGER]) +def test_boundary_iat_values_are_accepted(iat) -> None: + """Exact minimum and maximum from the TRACE v0.2 range should construct fine.""" + session = _make_session(iat=iat) + assert session.iat == iat diff --git a/tests/test_sandbox_adapter.py b/tests/test_sandbox_adapter.py index 4cc118bf..348ebcea 100644 --- a/tests/test_sandbox_adapter.py +++ b/tests/test_sandbox_adapter.py @@ -25,6 +25,7 @@ SandboxSessionResult, TraceSandboxAdapter, ) +from agentrust_trace.models import JCS_SAFE_INTEGER # --------------------------------------------------------------------------- # Fixtures @@ -319,8 +320,11 @@ def test_a_bad_sandbox_id_fails_at_the_adapter_not_at_model_validate() -> None: _make_session(sandbox_id="build-7f2a") -@pytest.mark.parametrize("iat", ["1800000000", True, False, -5, 1.5, 2**60]) -def test_iat_must_be_a_bounded_non_negative_integer(iat) -> None: +@pytest.mark.parametrize( + "iat", + ["1800000000", True, False, -5, 0, 1, 1699999999, 1.5, 2**60, JCS_SAFE_INTEGER + 1], +) +def test_iat_must_be_within_the_trace_v0_2_range(iat) -> None: """Every other field here reaches the record through a models.py pydantic constructor, which coerces or refuses a bad type before it is dumped. `iat` used to reach build_trust_record's top-level "iat" key untouched, so a @@ -328,7 +332,7 @@ def test_iat_must_be_a_bounded_non_negative_integer(iat) -> None: reported it valid (pydantic's lax mode coerces on the way in) while the wire bytes stayed the original, schema-invalid type. Same failure mode as provenance.build_record's pre-#320 issued_at coercion.""" - with pytest.raises(ValueError, match="iat must be a non-negative integer"): + with pytest.raises(ValueError, match="iat must be an integer Unix timestamp"): _make_session(iat=iat) @@ -337,6 +341,12 @@ def test_valid_iat_round_trips_as_an_int_on_the_wire() -> None: assert record["iat"] == 1800000000 assert isinstance(record["iat"], int) +@pytest.mark.parametrize("iat", [1700000000, JCS_SAFE_INTEGER]) +def test_boundary_iat_values_are_accepted(iat) -> None: + """The exact contract bounds from models.py must construct, not just values + inside them.""" + assert _make_session(iat=iat).iat == iat + # --------------------------------------------------------------------------- # 6. Transcript hashing uses JCS