diff --git a/burr/core/action.py b/burr/core/action.py index a69db06c6..62a4021f6 100644 --- a/burr/core/action.py +++ b/burr/core/action.py @@ -517,8 +517,9 @@ def visit_Set(self, node: ast.Set) -> None: def visit_Dict(self, node: ast.Dict) -> None: for k in node.keys: - if k is not None: - self.visit(k) + if k is None: + self._reject(node, "dictionary unpacking") + self.visit(k) for v in node.values: self.visit(v) @@ -810,7 +811,7 @@ def safe_expr(expr: str) -> "Condition": - ``BoolOp``: ``and``, ``or``. - ``UnaryOp``: ``not``, unary ``-``, unary ``+``. - ``BinOp`` arithmetic: ``+``, ``-``, ``*``, ``/``, ``//``, ``%``, ``**``. - - Literal containers: tuple, list, set, dict. + - Literal containers: tuple, list, set, dict. Unpacking is not supported. - ``Call`` only to a tight allowlist of safe builtins by name: ``len``, ``abs``, ``min``, ``max``, ``sum``, ``all``, ``any``, ``str``, ``int``, ``float``, ``bool``. All other calls are rejected. diff --git a/tests/core/test_action.py b/tests/core/test_action.py index 0095bdafe..213ba9ee2 100644 --- a/tests/core/test_action.py +++ b/tests/core/test_action.py @@ -418,6 +418,26 @@ def test_safe_expr_literal_containers(): assert _eval_value(Condition.safe_expr("{'a': 1, 'b': 2}"), {}) == {"a": 1, "b": 2} +def test_safe_expr_dict_with_none_key(): + cond = Condition.safe_expr("{None: 'fallback', 'allowed': True}[None] == 'fallback'") + assert cond.run(State({})) == {Condition.KEY: True} + + +@pytest.mark.parametrize( + "expr_str", + [ + "{**mapping}", + "{'allowed': True, **mapping}", + "{**first, **second}", + "[{**mapping}]", + "False and {**mapping}", + ], +) +def test_safe_expr_rejects_dict_unpacking_at_call_time(expr_str): + with pytest.raises(ValueError, match="dictionary unpacking"): + Condition.safe_expr(expr_str) + + def test_safe_expr_all_allowed_builtins(): pairs = [ ("len(items)", {"items": [1, 2, 3]}, 3),