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 = {