Skip to content

fix(conformance): catch RecursionError/TypeError and walk tuples in canonical_claim_bytes (#517) - #518

Open
drk-ram wants to merge 2 commits into
agentrust-io:mainfrom
drk-ram:drk-ram-fix-conformance-inclusion-recursion-and
Open

drk-ram wants to merge 2 commits into
agentrust-io:mainfrom
drk-ram:drk-ram-fix-conformance-inclusion-recursion-and

Conversation

@drk-ram

@drk-ram drk-ram commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Closes #517.

In conformance/src/trace_tests/inclusion.py:

  • canonical_claim_bytes previously called json.dumps(claim, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("ascii") without catching RecursionError or TypeError. Although _outside_profile walks the claim iteratively ("because the claim is attacker-controlled and may be deep"), json.dumps is recursive and raised an unhandled RecursionError on deeply nested claims (or TypeError on non-JSON-serializable values), escaping tr_anc.check and crashing runner.run when a receipt was supplied. canonical_claim_bytes now catches RecursionError and TypeError and raises InclusionError("claim nests too deeply to serialize") and InclusionError(f"claim contains a value that is not JSON serializable: {exc}") respectively (matching src/agentrust_trace/sign.py:anchor_bytes), so tr_anc.check reports a TR-ANC-002 Status.FAIL finding instead of raising.
  • _outside_profile previously descended only into dict and list, allowing a tuple containing a float or out-of-range integer (such as {"a": (1.5,)} or {"a": (2**53,)}) to bypass _outside_profile and serialize as a JSON array via json.dumps. _outside_profile now descends into (list, tuple).

In conformance/tests/test_modules_never_raise.py and conformance/tests/unit/test_tr_anc.py:

  • Added tr_apr to MODULES and (("appraisal", "status"), "TR-APR-001") to NESTED_DISCRIMINANTS in conformance/tests/test_modules_never_raise.py so all eight conformance modules and their nested discriminator fields are swept.
  • Added test_tr_anc_with_receipt_never_raises_on_malformed_or_deep_records in conformance/tests/test_modules_never_raise.py so tr_anc.check is exercised with a well-formed receipt across all field mutations as well as deeply nested, non-JSON, and tuple-wrapped claims.
  • Added unit tests in conformance/tests/unit/test_tr_anc.py for canonical_claim_bytes and tr_anc.check on deeply nested claims, non-JSON values, and tuples carrying floats or unsafe integers.

Cold-reader review:
A cold review of the initial diff against conformance/src/trace_tests/modules/tr_apr.py found that tr_apr.py:167-169 comments # in against a frozenset raises on an unhashable value, and a record is not trusted to carry a hashable one; test_modules_never_raise fuzzes this field with a list, but adding tr_apr to MODULES alone only fuzzed top-level record["appraisal"] (which returns early at if not isinstance(appraisal, dict)) and never mutated record["appraisal"]["status"] because ("appraisal", "status") was absent from NESTED_DISCRIMINANTS. Added (("appraisal", "status"), "TR-APR-001") to NESTED_DISCRIMINANTS (and "appraisal" to test_runner_fails_nested_discriminators_instead_of_raising) so appraisal.status is fuzzed with lists, objects, and all JUNK values as tr_apr.py documents.

Contributor: Raghuvar Durga (@drk-ram)

Type of change

  • Editorial (typo, link fix, clarification: no normative effect)
  • Non-breaking spec change (new optional field, new platform profile, informative addition)
  • Breaking spec change (requires 14-day comment period and Project Lead sign-off)
  • Schema change
  • Example addition

Spec section

None (conformance test suite fix in conformance/src/trace_tests/inclusion.py, conformance/tests/test_modules_never_raise.py, and conformance/tests/unit/test_tr_anc.py).

Tests for a fix

  1. Deeply nested claim (RecursionError -> InclusionError / TR-ANC-002 Status.FAIL): conformance/tests/unit/test_tr_anc.py::test_deeply_nested_claim_raises_inclusion_error_and_fails_tr_anc_002 (fails with RecursionError: maximum recursion depth exceeded while encoding a JSON object when the except RecursionError: handler in canonical_claim_bytes is switched off):

    def test_deeply_nested_claim_raises_inclusion_error_and_fails_tr_anc_002():
        claim = {
            "transparency": "https://registry.example.org/claim/1",
            "cnf": {
                "jwk": {
                    "kty": "OKP",
                    "crv": "Ed25519",
                    "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
                    "ext": _nested(100_000),
                }
            },
        }
        with pytest.raises(InclusionError, match="nests too deeply"):
            canonical_claim_bytes(claim)
    
        findings = check(claim, receipt=dict(_RECEIPT))
        by_code = {f.code: f for f in findings}
        assert by_code["TR-ANC-002"].status is Status.FAIL
        assert "nests too deeply" in by_code["TR-ANC-002"].message
  2. Non-JSON-serializable value (TypeError -> InclusionError / TR-ANC-002 Status.FAIL): conformance/tests/unit/test_tr_anc.py::test_non_json_serializable_claim_raises_inclusion_error_and_fails_tr_anc_002 (fails with TypeError: Object of type object is not JSON serializable when the except TypeError as exc: handler in canonical_claim_bytes is switched off):

    def test_non_json_serializable_claim_raises_inclusion_error_and_fails_tr_anc_002():
        claim = {
            "transparency": "https://registry.example.org/claim/1",
            "extra": object(),
        }
        with pytest.raises(InclusionError, match="not JSON serializable"):
            canonical_claim_bytes(claim)
    
        findings = check(claim, receipt=dict(_RECEIPT))
        by_code = {f.code: f for f in findings}
        assert by_code["TR-ANC-002"].status is Status.FAIL
        assert "not JSON serializable" in by_code["TR-ANC-002"].message
  3. Tuple containing a float or out-of-range integer (_outside_profile walking (list, tuple)): conformance/tests/unit/test_tr_anc.py::test_tuple_outside_anchor_profile_raises_inclusion_error_and_fails_tr_anc_002 (fails with Failed: DID NOT RAISE <class 'trace_tests.inclusion.InclusionError'> when isinstance(v, (list, tuple)) in _outside_profile is reverted to isinstance(v, list)):

    @pytest.mark.parametrize(
        ("claim", "expected"),
        [
            ({"a": (1.5,)}, "non-integer number"),
            ({"a": (2**53,)}, "outside the safe-integer range"),
        ],
        ids=["tuple-float", "tuple-unsafe-int"],
    )
    def test_tuple_outside_anchor_profile_raises_inclusion_error_and_fails_tr_anc_002(
        claim: dict[str, Any], expected: str
    ):
        with pytest.raises(InclusionError, match=expected):
            canonical_claim_bytes(claim)
    
        trace = {"transparency": "https://registry.example.org/claim/1", **claim}
        findings = check(trace, receipt=dict(_RECEIPT))
        by_code = {f.code: f for f in findings}
        assert by_code["TR-ANC-002"].status is Status.FAIL
        assert expected in by_code["TR-ANC-002"].message

Validation Commands Run

  • python tools/check_dashes.py: passed (no dashes outside examples/, spec/trace-v0.1.md)
  • python -m ruff check src tests scripts conformance/src/trace_tests/inclusion.py conformance/tests/test_modules_never_raise.py conformance/tests/unit/test_tr_anc.py: passed (All checks passed!)
  • python -m mypy src/agentrust_trace conformance/src/trace_tests/inclusion.py: passed (Success: no issues found in 16 source files)
  • python -m pytest conformance/tests/test_modules_never_raise.py conformance/tests/unit/test_tr_anc.py conformance/tests/test_tr_anc_inclusion.py -v: passed (63 passed)
  • Push-Location conformance; python -m pytest tests -q; Pop-Location: passed (726 passed, 5 xpassed)
  • python -m pytest tests/test_conformance_import_style.py -v: passed (2 passed)

Checklist

  • DCO sign-off on all commits (git commit -s)
  • CHANGELOG.md updated (for any normative change)
  • Breaking changes marked with <!-- CHANGED: #NNN: description --> in spec text
  • Backward compatibility statement included (for breaking changes)

…anonical_claim_bytes

- Catch RecursionError and TypeError from json.dumps in
  trace_tests.inclusion.canonical_claim_bytes and raise InclusionError
  so tr_anc.check reports a TR-ANC-002 FAIL finding instead of escaping
  runner.run when a receipt is supplied (agentrust-io#517).
- Walk tuple alongside list in trace_tests.inclusion._outside_profile so
  tuples carrying floats or out-of-range integers cannot bypass the
  anchor-leaf profile check.
- Include tr_apr and appraisal.status in test_modules_never_raise and
  exercise tr_anc.check with a well-formed receipt across field mutations
  and deep/non-JSON/tuple claims.

Closes agentrust-io#517

Signed-off-by: Raghuvar Durga <177662415+drk-ram@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@drk-ram
drk-ram requested review from a team, lywinged and rajnisht7 as code owners October 14, 2026 22:36
…thon 3.12+

In Python 3.12+ on Linux, C-extension json.dumps checks the C stack via
_PyOS_CheckStack rather than sys.getrecursionlimit(), so depth 2000 does
not trigger RecursionError. Use depth 100_000 (matching
test_untrusted_input_hardening.py).

Signed-off-by: Raghuvar Durga <177662415+drk-ram@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

conformance: inclusion.canonical_claim_bytes raises unhandled RecursionError on deep claims when TR-ANC-002 runs with a receipt

1 participant