diff --git a/tests/tools/test_math_animate_safety.py b/tests/tools/test_math_animate_safety.py index a83ced7f..561bbe06 100644 --- a/tests/tools/test_math_animate_safety.py +++ b/tests/tools/test_math_animate_safety.py @@ -103,8 +103,38 @@ def test_blocks_sandbox_escape_dunders(): " ().__class__.__bases__[0].__subclasses__()\n" ) violations = MathAnimate._scan_scene_code(code) - assert "attribute access '.__bases__'" in violations - assert "attribute access '.__subclasses__'" in violations + assert "dunder attribute access '.__class__'" in violations + assert "dunder attribute access '.__bases__'" in violations + assert "dunder attribute access '.__subclasses__'" in violations + + +def test_blocks_builtins_module_via_print_self(): + # Regression for the reported no-import bypass: print.__self__ is the + # builtins module, reachable without an import, a bare open/__builtins__/ + # getattr, or a blocked name. Blocking all reflection dunders closes it. + code = ( + "from manim import *\n" + "class S(Scene):\n" + " def construct(self):\n" + " print.__self__.open('.env').read()\n" + ) + assert "dunder attribute access '.__self__'" in MathAnimate._scan_scene_code(code) + + +def test_super_init_is_allowed(): + # A legitimate custom Mobject with super().__init__() must not be blocked — + # __init__ (and __name__) are the only permitted dunders. + code = ( + "from manim import *\n" + "class Widget(VGroup):\n" + " def __init__(self, **kwargs):\n" + " super().__init__(**kwargs)\n" + " self.add(Circle())\n" + "class S(Scene):\n" + " def construct(self):\n" + " self.add(Widget())\n" + ) + assert MathAnimate._scan_scene_code(code) == [] def test_syntax_error_defers_to_manim(): diff --git a/tools/graphics/math_animate.py b/tools/graphics/math_animate.py index 63be79b7..f7c55b1b 100644 --- a/tools/graphics/math_animate.py +++ b/tools/graphics/math_animate.py @@ -54,12 +54,21 @@ _BLOCKED_NAMES = frozenset({ "__builtins__", "__loader__", "globals", "locals", "vars", "getattr", "setattr", "delattr", }) -# Sandbox-escape / reflection dunders blocked as attribute access. -_BLOCKED_ATTRS = frozenset({ - "__globals__", "__builtins__", "__subclasses__", "__bases__", "__base__", - "__mro__", "__code__", "__class__", "__dict__", "__getattribute__", - "__closure__", "__reduce__", "__reduce_ex__", "__subclasshook__", -}) +# Reflection via dunder attributes is the general escape hatch: `().__class__`, +# `print.__self__` (the builtins module), `x.__globals__`, `f.__reduce__`, etc. +# Enumerating dangerous dunders one by one is whack-a-mole, so block ALL dunder +# *attribute access* and allow only a tiny set that legitimate scenes use +# (`super().__init__(...)`, occasional `Type.__name__`). A dunder is any name +# that starts and ends with double underscores. +_ALLOWED_DUNDER_ATTRS = frozenset({"__init__", "__name__"}) + + +def _is_blocked_dunder(attr: str) -> bool: + return ( + attr.startswith("__") + and attr.endswith("__") + and attr not in _ALLOWED_DUNDER_ATTRS + ) # Quality presets mapping to Manim CLI flags @@ -244,8 +253,8 @@ class MathAnimate(BaseTool): if node.id in _BLOCKED_NAMES: violations.append(f"use of '{node.id}'") elif isinstance(node, ast.Attribute): - if node.attr in _BLOCKED_ATTRS: - violations.append(f"attribute access '.{node.attr}'") + if _is_blocked_dunder(node.attr): + violations.append(f"dunder attribute access '.{node.attr}'") seen: set[str] = set() deduped: list[str] = []