diff --git a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py index aa06ab7fc..995fb09dc 100644 --- a/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py +++ b/libs/checkpoint/langgraph/checkpoint/serde/jsonplus.py @@ -68,6 +68,8 @@ SAFE_MSGPACK_TYPES: frozenset[tuple[str, ...]] = frozenset( ("pathlib._local", "Path"), ("pathlib._local", "PosixPath"), ("pathlib._local", "WindowsPath"), + # regex + ("re", "compile"), # langgraph ("langgraph.types", "Send"), ("langgraph.types", "Interrupt"), diff --git a/libs/checkpoint/tests/test_jsonplus.py b/libs/checkpoint/tests/test_jsonplus.py index 3b2f0d072..1fde8087d 100644 --- a/libs/checkpoint/tests/test_jsonplus.py +++ b/libs/checkpoint/tests/test_jsonplus.py @@ -37,6 +37,10 @@ class MyPydantic(BaseModel): inner: InnerPydantic +class AnotherPydantic(BaseModel): + foo: str + + class InnerPydanticV1(BaseModelV1): hello: str @@ -597,3 +601,155 @@ def test_msgpack_none_blocks_unregistered(caplog: pytest.LogCaptureFixture) -> N assert "blocked" in caplog.text.lower() assert result is None + + +def test_msgpack_allowlist_blocks_non_listed( + caplog: pytest.LogCaptureFixture, +) -> None: + """Allowlists should block unregistered types even if msgpack is enabled.""" + + serde = JsonPlusSerializer( + allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")] + ) + + obj = AnotherPydantic(foo="nope") + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "blocked" in caplog.text.lower() + assert result is None + + +def test_msgpack_strict_allows_safe_types( + caplog: pytest.LogCaptureFixture, +) -> None: + """Safe types should still deserialize in strict mode without warnings.""" + + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + safe = uuid.uuid4() + + caplog.clear() + dumped = serde.dumps_typed(safe) + result = serde.loads_typed(dumped) + + assert "blocked" not in caplog.text.lower() + assert result == safe + + +def test_msgpack_regex_safe_type(caplog: pytest.LogCaptureFixture) -> None: + """re.compile patterns should deserialize without warnings as a safe type.""" + + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + pattern = re.compile(r"foo.*bar", re.IGNORECASE | re.DOTALL) + + caplog.clear() + dumped = serde.dumps_typed(pattern) + result = serde.loads_typed(dumped) + + assert "blocked" not in caplog.text.lower() + assert "unregistered" not in caplog.text.lower() + assert result.pattern == pattern.pattern + assert result.flags == pattern.flags + + +@pytest.mark.skipif(sys.version_info >= (3, 14), reason="pydantic v1 not on 3.14+") +def test_msgpack_pydantic_v1_allowlist(caplog: pytest.LogCaptureFixture) -> None: + """Pydantic v1 models in allowlist should deserialize without warnings.""" + + serde = JsonPlusSerializer( + allowed_msgpack_modules=[ + ("tests.test_jsonplus", "MyPydanticV1"), + ("tests.test_jsonplus", "InnerPydanticV1"), + ] + ) + + obj = MyPydanticV1(foo="test", bar=42, inner=InnerPydanticV1(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "unregistered type" not in caplog.text.lower() + assert "blocked" not in caplog.text.lower() + assert result == obj + + +def test_msgpack_dataclass_allowlist(caplog: pytest.LogCaptureFixture) -> None: + """Dataclasses in allowlist should deserialize without warnings.""" + + serde = JsonPlusSerializer( + allowed_msgpack_modules=[ + ("tests.test_jsonplus", "MyDataclass"), + ("tests.test_jsonplus", "InnerDataclass"), + ] + ) + + obj = MyDataclass(foo="test", bar=42, inner=InnerDataclass(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "unregistered type" not in caplog.text.lower() + assert "blocked" not in caplog.text.lower() + assert result == obj + + +def test_msgpack_safe_types_value_equality(caplog: pytest.LogCaptureFixture) -> None: + """Verify safe types are correctly restored with proper values.""" + + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + + test_cases = [ + datetime(2024, 1, 15, 12, 30, 45, 123456), + date(2024, 6, 15), + time(14, 30, 0), + uuid.UUID("12345678-1234-5678-1234-567812345678"), + Decimal("123.456789"), + {1, 2, 3, 4, 5}, + frozenset(["a", "b", "c"]), + deque([1, 2, 3]), + IPv4Address("10.0.0.1"), + pathlib.Path("/some/test/path"), + re.compile(r"\d+", re.MULTILINE), + ] + + for obj in test_cases: + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + assert "blocked" not in caplog.text.lower(), f"Blocked for {type(obj)}" + # For regex patterns, compare pattern and flags + if isinstance(obj, re.Pattern): + assert result.pattern == obj.pattern + assert result.flags == obj.flags + else: + assert result == obj, f"Value mismatch for {type(obj)}: {result} != {obj}" + + +def test_msgpack_nested_pydantic_serializes_as_dict( + caplog: pytest.LogCaptureFixture, +) -> None: + """Nested Pydantic models are serialized via model_dump() as dicts. + + This means nested models don't go through the ext hook and don't need + to be in the allowlist - only the outer type does. + """ + + # Only allow outer type - inner is serialized as dict via model_dump() + serde = JsonPlusSerializer( + allowed_msgpack_modules=[("tests.test_jsonplus", "MyPydantic")] + ) + + obj = MyPydantic(foo="test", bar=42, inner=InnerPydantic(hello="world")) + + caplog.clear() + dumped = serde.dumps_typed(obj) + result = serde.loads_typed(dumped) + + # No blocking should occur - inner is serialized as dict, not ext + assert "blocked" not in caplog.text.lower() + assert result == obj diff --git a/libs/checkpoint/tests/test_memory.py b/libs/checkpoint/tests/test_memory.py index 7d85f4ed5..766cc1210 100644 --- a/libs/checkpoint/tests/test_memory.py +++ b/libs/checkpoint/tests/test_memory.py @@ -1,7 +1,9 @@ +import logging from typing import Any import pytest from langchain_core.runnables import RunnableConfig +from pydantic import BaseModel from langgraph.checkpoint.base import ( Checkpoint, @@ -10,6 +12,11 @@ from langgraph.checkpoint.base import ( empty_checkpoint, ) from langgraph.checkpoint.memory import InMemorySaver +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + + +class MemoryPydantic(BaseModel): + foo: str class TestMemorySaver: @@ -199,3 +206,77 @@ async def test_memory_saver() -> None: with memory_saver as sync_memory_saver: assert sync_memory_saver is memory_saver + + +def test_memory_saver_warns_on_unregistered_msgpack( + caplog: pytest.LogCaptureFixture, +) -> None: + serde = JsonPlusSerializer() + memory_saver = InMemorySaver(serde=serde) + obj = MemoryPydantic(foo="bar") + + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"foo": obj} + checkpoint["channel_versions"] = {"foo": 1} + + config: RunnableConfig = { + "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""} + } + + caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus") + new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1}) + result = memory_saver.get_tuple(new_config) + + assert result is not None + assert "unregistered type" in caplog.text.lower() + assert result.checkpoint["channel_values"]["foo"] == obj + + +def test_memory_saver_allowlist_silences_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + serde = JsonPlusSerializer( + allowed_msgpack_modules=[("tests.test_memory", "MemoryPydantic")] + ) + memory_saver = InMemorySaver(serde=serde) + obj = MemoryPydantic(foo="bar") + + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"foo": obj} + checkpoint["channel_versions"] = {"foo": 1} + + config: RunnableConfig = { + "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""} + } + + caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus") + new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1}) + result = memory_saver.get_tuple(new_config) + + assert result is not None + assert "unregistered type" not in caplog.text.lower() + assert result.checkpoint["channel_values"]["foo"] == obj + + +def test_memory_saver_strict_blocks_unregistered( + caplog: pytest.LogCaptureFixture, +) -> None: + serde = JsonPlusSerializer(allowed_msgpack_modules=None) + memory_saver = InMemorySaver(serde=serde) + obj = MemoryPydantic(foo="bar") + + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = {"foo": obj} + checkpoint["channel_versions"] = {"foo": 1} + + config: RunnableConfig = { + "configurable": {"thread_id": "thread-1", "checkpoint_ns": ""} + } + + caplog.set_level(logging.WARNING, logger="langgraph.checkpoint.serde.jsonplus") + new_config = memory_saver.put(config, checkpoint, {}, {"foo": 1}) + result = memory_saver.get_tuple(new_config) + + assert result is not None + assert "blocked" in caplog.text.lower() + assert result.checkpoint["channel_values"]["foo"] is None diff --git a/libs/cli/langgraph_cli/schemas.py b/libs/cli/langgraph_cli/schemas.py index f190eaf94..f601f816a 100644 --- a/libs/cli/langgraph_cli/schemas.py +++ b/libs/cli/langgraph_cli/schemas.py @@ -165,7 +165,7 @@ class SerdeConfig(TypedDict, total=False): {... "serde": { "allowed_msgpack_modules": [ - ["my_agent", "models", "MyState"], + ["my_agent.models", "MyState"], ] } } diff --git a/libs/cli/schemas/schema.json b/libs/cli/schemas/schema.json index 52e0c7ed6..bcd910286 100644 --- a/libs/cli/schemas/schema.json +++ b/libs/cli/schemas/schema.json @@ -611,7 +611,7 @@ "type": "null" } ], - "description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent\", \"models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n" + "description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent.models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n" }, "pickle_fallback": { "type": "boolean", diff --git a/libs/cli/schemas/schema.v0.json b/libs/cli/schemas/schema.v0.json index 52e0c7ed6..bcd910286 100644 --- a/libs/cli/schemas/schema.v0.json +++ b/libs/cli/schemas/schema.v0.json @@ -611,7 +611,7 @@ "type": "null" } ], - "description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent\", \"models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n" + "description": "Optional. List of allowed python modules to de-serialize custom objects from msgpack.\n\nKnown safe types (langgraph.checkpoint.serde.jsonplus.SAFE_MSGPACK_TYPES) are always\nallowed regardless of this setting. Use this to allowlist your custom Pydantic models,\ndataclasses, and other user-defined types.\n\nIf True (default), unregistered types will log a warning but still be deserialized.\nIf None, only known safe types will be deserialized; unregistered types will be blocked.\n\n{...\n[\"my_agent.models\", \"MyState\"],\n]\n}\n}\n\n{...\n}\n}\n\n" }, "pickle_fallback": { "type": "boolean",