diff --git a/src/agentrust_trace/intent_bridge.py b/src/agentrust_trace/intent_bridge.py index 2d91c91..84ffe64 100644 --- a/src/agentrust_trace/intent_bridge.py +++ b/src/agentrust_trace/intent_bridge.py @@ -240,20 +240,17 @@ def verify_bridge( if not isinstance(transcript, dict) or set(transcript) != {"before", "after"}: raise AuthorizationMismatch("a full before/after transcript is required") before = transcript.get("before") - if not isinstance(before, dict) or not isinstance(before.get("tool_call"), dict): + before_call = before.get("tool_call") if isinstance(before, dict) else None + if not isinstance(before_call, dict): raise AuthorizationMismatch("transcript.before.tool_call does not match execution") # Host-language equality is not this bridge's identity relation. Python holds - # True == 1 and False == 0, nested objects included, so comparing the two call - # objects with != accepts a transcript whose call has different JCS bytes from - # the one the authorization digested (#317). Every other comparison in this - # function is over canonical bytes; so is this one. - try: - before_digest = digest_jcs(before["tool_call"]) - except IntentBridgeError: - raise AuthorizationMismatch( - "transcript.before.tool_call does not match execution" - ) from None - if not compare_digest(before_digest, tool_call_digest): + # True == 1 and False == 0, nested objects included, so compare the exact RFC + # 8785 bytes instead. If either object has no canonical form, _jcs raises + # IntentBridgeError: that input cannot be evaluated, which is not a mismatch. + if not compare_digest( + _jcs(before_call, "transcript.before.tool_call"), + _jcs(tool_call, "tool_call"), + ): raise AuthorizationMismatch("transcript.before.tool_call does not match execution") if not isinstance(transcript.get("after"), dict): raise AuthorizationMismatch("transcript.after must contain the execution result") diff --git a/tests/test_intent_bridge.py b/tests/test_intent_bridge.py index 3f6b62f..120bec3 100644 --- a/tests/test_intent_bridge.py +++ b/tests/test_intent_bridge.py @@ -355,3 +355,19 @@ def test_transcript_call_that_is_not_an_object_stays_an_authorization_mismatch( transcript={"before": {"tool_call": bad}, "after": {"status": "accepted"}}, now=150, ) + +@pytest.mark.parametrize("bad", [{"approved": 2**60}, {"approved": float("nan")}]) +def test_uncanonicalizable_transcript_call_is_intentbridgeerror_not_mismatch( + bad: dict, +) -> None: + """An unrepresentable transcript call cannot be evaluated; it is not a mismatch.""" + bridge, key, declaration, intent, args, tool_call, _ = _fixture() + transcript_call = {"name": "send_invoice", "arguments": bad} + with pytest.raises(IntentBridgeError, match="transcript.before.tool_call") as excinfo: + verify_bridge( + bridge, {**key_to_jwk(key), "kid": "key-7"}, declaration=declaration, + pic_intent_digest=intent, pic_args_digest=args, tool_call=tool_call, + transcript={"before": {"tool_call": transcript_call}, "after": {"status": "accepted"}}, + now=150, + ) + assert not isinstance(excinfo.value, AuthorizationMismatch)