From 85cd64ed69e6f4d794804dd3a8feec2d3ead6304 Mon Sep 17 00:00:00 2001 From: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:08:47 -0400 Subject: [PATCH] fix(checkpoint): revive lc=2 JSON blobs for safe types without allowlist (#7582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #7498 — `MESSAGE_COERCION_FAILURE` when resuming threads checkpointed before v1.0.1. **Root cause:** PR #6269 (v1.0.1) added an `_allowed_json_modules` security gate to `JsonPlusSerializer._reviver`. The gate defaults to `None`, so old `"json"`-format checkpoint blobs containing `lc=2` constructor dicts (the pre-msgpack serialization format for pydantic objects like `HumanMessage`) are now returned as raw dicts instead of being reconstructed. Those raw dicts reach `add_messages → convert_to_messages`, which sees `type="constructor"` and raises `MESSAGE_COERCION_FAILURE`. Fresh first-turn messages are unaffected because current `dumps_typed` only writes `"msgpack"` blobs. **Fix:** `_reviver` now reconstructs `lc=2` blobs whose target class is already in `SAFE_MSGPACK_TYPES` — the same curated allowlist already used by the msgpack deserialization path (includes all standard LangChain message types). Unknown classes are still blocked, preserving the security intent of #6269. ## Changes - `libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py` — add `_is_safe_json_type()` helper; update `_reviver` and `_check_allowed_json_modules` to allow safe types without an explicit allowlist - `libs/checkpoint/tests/test_jsonplus.py` — two new regression tests: safe-type `lc=2` blobs revive correctly; unknown-type `lc=2` blobs stay blocked ## Test plan - [ ] `test_lc2_json_safe_type_revives_without_allowlist` — `HumanMessage`/`AIMessage` lc=2 JSON blobs round-trip to proper `BaseMessage` objects with no allowlist configured - [ ] `test_lc2_json_unknown_type_stays_blocked_without_allowlist` — `pprint.pprint` lc=2 blob still returns raw dict (not reconstructed) - [ ] `test_deserde_invalid_module` — existing behaviour unchanged - [ ] Full `test_jsonplus.py` suite: 93/93 passing Co-authored-by: Claude Sonnet 4.6 --- .../langgraph/checkpoint/serde/jsonplus.py | 42 +++++++++++---- libs/checkpoint/tests/test_jsonplus.py | 51 +++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py index c4adcb484..98902d502 100644 --- a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py +++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py @@ -55,6 +55,19 @@ _warned_unregistered_types: set[tuple[str, str]] = set() _warned_blocked_types: set[tuple[str, str]] = set() +def _is_safe_json_type(id_list: list[str]) -> bool: + """Return True if an lc=2 id refers to a type in SAFE_MSGPACK_TYPES. + + Safe types bypass the ``allowed_json_modules`` gate so that old "json" format + checkpoints (written before the msgpack migration) can be resumed without + requiring users to configure an explicit allowlist. + """ + if len(id_list) < 2: + return False + module_name = ".".join(id_list[:-1]) + return (module_name, id_list[-1]) in _lg_msgpack.SAFE_MSGPACK_TYPES + + def _warn_once( seen: set[tuple[str, str]], key: tuple[str, str], msg: str, *args: object ) -> None: @@ -164,19 +177,23 @@ class JsonPlusSerializer(SerializerProtocol): return out def _reviver(self, value: dict[str, Any]) -> Any: - if self._allowed_json_modules and ( + if ( value.get("lc", None) == 2 and value.get("type", None) == "constructor" and value.get("id", None) is not None ): - try: - return self._revive_lc2(value) - except InvalidModuleError as e: - logger.warning( - "Object %s is not in the deserialization allowlist.\n%s", - value["id"], - e.message, - ) + id_list = value["id"] + is_safe = _is_safe_json_type(id_list) + if self._allowed_json_modules or is_safe: + try: + return self._revive_lc2(value) + except InvalidModuleError as e: + if not is_safe: + logger.warning( + "Object %s is not in the deserialization allowlist.\n%s", + value["id"], + e.message, + ) return LC_REVIVER(value) @@ -224,6 +241,13 @@ class JsonPlusSerializer(SerializerProtocol): method_display = "" dotted = ".".join(needed) + # Safe types (the same set already allowed for msgpack deserialization) are + # permitted without an explicit allowlist — they are known-safe LangGraph and + # LangChain types. This restores backwards-compat for old "json" checkpoints + # that pre-date the msgpack migration without reopening the broader security gate. + if _is_safe_json_type(list(needed)): + return + if not self._allowed_json_modules: raise InvalidModuleError( f"Refused to deserialize JSON constructor: {dotted} (method: {method_display}). " diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py index 37200b964..15c54e6c0 100644 --- a/libs/checkpoint/tests/test_jsonplus.py +++ b/libs/checkpoint/tests/test_jsonplus.py @@ -333,6 +333,57 @@ def test_serde_jsonplus_bytes() -> None: assert serde.loads_typed(dumped) == some_bytes +def test_lc2_json_safe_type_revives_without_allowlist() -> None: + """Old 'json' blobs with lc=2 for safe types must revive without an explicit allowlist. + + Regression test for: https://github.com/langchain-ai/langgraph/issues/7498 + Threads checkpointed before v1.0.1 (pre-msgpack) stored messages as lc=2 JSON + constructor dicts. Resuming those threads must reconstruct proper BaseMessage objects + rather than returning raw dicts that cause MESSAGE_COERCION_FAILURE in add_messages. + """ + from langchain_core.messages import AIMessage + + serde = JsonPlusSerializer() # default: _allowed_json_modules=None + + human_blob = { + "lc": 2, + "type": "constructor", + "id": ["langchain_core", "messages", "human", "HumanMessage"], + "kwargs": {"content": "hello", "type": "human"}, + } + ai_blob = { + "lc": 2, + "type": "constructor", + "id": ["langchain_core", "messages", "ai", "AIMessage"], + "kwargs": {"content": "hi there", "type": "ai"}, + } + result = serde.loads_typed(("json", json.dumps([human_blob, ai_blob]).encode())) + + assert len(result) == 2 + assert isinstance(result[0], HumanMessage), ( + f"Expected HumanMessage, got {type(result[0])}: {result[0]!r}\n" + "lc=2 JSON blobs for safe types must deserialize without an explicit allowlist" + ) + assert result[0].content == "hello" + assert isinstance(result[1], AIMessage) + assert result[1].content == "hi there" + + +def test_lc2_json_unknown_type_stays_blocked_without_allowlist() -> None: + """lc=2 JSON blobs for types NOT in SAFE_MSGPACK_TYPES still require an allowlist.""" + serde = JsonPlusSerializer() + load = { + "lc": 2, + "type": "constructor", + "id": ["pprint", "pprint"], + "kwargs": {"object": "HELLO"}, + } + # No allowlist configured → raw dict returned (not raised, not reconstructed) + result = serde.loads_typed(("json", json.dumps(load).encode())) + assert isinstance(result, dict), "Unknown lc=2 type must stay as raw dict" + assert result.get("lc") == 2 + + def test_deserde_invalid_module() -> None: serde = JsonPlusSerializer() load = {