diff --git a/mesop/dataclass_utils/dataclass_utils.py b/mesop/dataclass_utils/dataclass_utils.py index 502ce63a..42297aaa 100644 --- a/mesop/dataclass_utils/dataclass_utils.py +++ b/mesop/dataclass_utils/dataclass_utils.py @@ -133,6 +133,14 @@ def _recursive_update_dataclass_from_json_obj(instance: Any, json_dict: Any): raise MesopDeveloperException( f"Cannot use dunder property: {key} in stateclass" ) + if ( + not isinstance(instance, dict) + and hasattr(instance, key) + and key not in getattr(instance, "__dataclass_fields__", {}) + ): + raise MesopDeveloperException( + f"Cannot set non-dataclass-field property: {key} in stateclass" + ) if hasattr(instance, key): attr = getattr(instance, key) if isinstance(value, dict): diff --git a/mesop/dataclass_utils/dataclass_utils_test.py b/mesop/dataclass_utils/dataclass_utils_test.py index f04a7a1c..c413cb18 100644 --- a/mesop/dataclass_utils/dataclass_utils_test.py +++ b/mesop/dataclass_utils/dataclass_utils_test.py @@ -612,5 +612,37 @@ class A: assert __name__ == initial_name +def test_class_level_attribute_pollution_blocked(): + """Regression test for class-level mutable attribute pollution. + + A non-dunder key that resolves to a class-level attribute (rather than a + declared dataclass field) must be rejected, since setting it would mutate + state shared across all sessions instead of the per-instance field. + """ + + class MutableRoleMap(dict): + __hash__ = object.__hash__ # type: ignore (mimic a hashable-but-mutable gadget) + + class RoleService: + # Class-level (not annotated, so not a dataclass field) shared mapping. + role_map = MutableRoleMap({"assistant": "user"}) + + @dataclass + class ChatState: + service: RoleService = field(default_factory=RoleService) + + state = ChatState() + with pytest.raises(MesopDeveloperException) as exc_info: + update_dataclass_from_json( + state, '{"service": {"role_map": {"assistant": "system"}}}' + ) + assert ( + "Cannot set non-dataclass-field property: role_map in stateclass" + in str(exc_info.value) + ) + # Make sure the shared class-level mapping was not mutated. + assert RoleService.role_map == {"assistant": "user"} + + if __name__ == "__main__": raise SystemExit(pytest.main(["-vv", __file__]))