mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-25 01:20:18 +08:00
fix(math_animate): gate caller-supplied scene_code execution (#219)
math_animate writes caller-supplied Python to scene.py and runs Manim on it — arbitrary local code execution with no boundary surfaced in the tool contract. In an agent-driven system the scene_code may be LLM-generated or influenced by untrusted prompt content, so import-time code or construct() could read secrets/SSH material, open network connections, or spawn subprocesses. Add a static AST safety scan that rejects dangerous imports (os, subprocess, socket, requests, ctypes, ...), dangerous builtins (eval/exec/compile/open/ __import__), and sandbox-escape dunders (__globals__, __subclasses__, ...) before Manim runs. Genuine math scenes (manim, numpy, math, ...) pass untouched. This is defense-in-depth, not a sandbox: a determined attacker can evade a static denylist, so it is paired with an explicit allow_unsafe_code opt-out and a tool contract (schema + side_effects) that names the boundary. Closes #219
This commit is contained in:
122
tests/tools/test_math_animate_safety.py
Normal file
122
tests/tools/test_math_animate_safety.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Tests for math_animate scene_code safety scan (issue #219).
|
||||
|
||||
math_animate executes caller-supplied Python via Manim. The static scan blocks
|
||||
the constructs an attack needs (system/network/subprocess/secret access) while
|
||||
leaving genuine math-animation scenes untouched, and can be bypassed only with
|
||||
an explicit allow_unsafe_code opt-out.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tools.graphics.math_animate import MathAnimate # noqa: E402
|
||||
|
||||
SAFE_SCENE = (
|
||||
"from manim import *\n"
|
||||
"import numpy as np\n"
|
||||
"import math\n"
|
||||
"class Demo(Scene):\n"
|
||||
" def construct(self):\n"
|
||||
" self.play(Create(Circle(radius=np.pi / math.tau)))\n"
|
||||
)
|
||||
|
||||
|
||||
def test_safe_scene_passes_scan():
|
||||
assert MathAnimate._scan_scene_code(SAFE_SCENE) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"snippet, needle",
|
||||
[
|
||||
("import os\nos.environ", "import 'os'"),
|
||||
("import subprocess", "import 'subprocess'"),
|
||||
("import socket", "import 'socket'"),
|
||||
("from urllib.request import urlopen", "from 'urllib.request' import ..."),
|
||||
("import requests", "import 'requests'"),
|
||||
],
|
||||
)
|
||||
def test_blocks_dangerous_imports(snippet, needle):
|
||||
code = f"from manim import *\n{snippet}\nclass S(Scene):\n def construct(self):\n pass\n"
|
||||
violations = MathAnimate._scan_scene_code(code)
|
||||
assert needle in violations
|
||||
|
||||
|
||||
@pytest.mark.parametrize("call", ["eval", "exec", "compile", "open", "__import__"])
|
||||
def test_blocks_dangerous_calls(call):
|
||||
code = (
|
||||
"from manim import *\n"
|
||||
"class S(Scene):\n"
|
||||
" def construct(self):\n"
|
||||
f" {call}('x')\n"
|
||||
)
|
||||
assert f"call to '{call}()'" in MathAnimate._scan_scene_code(code)
|
||||
|
||||
|
||||
def test_blocks_sandbox_escape_dunders():
|
||||
code = (
|
||||
"from manim import *\n"
|
||||
"class S(Scene):\n"
|
||||
" def construct(self):\n"
|
||||
" ().__class__.__bases__[0].__subclasses__()\n"
|
||||
)
|
||||
violations = MathAnimate._scan_scene_code(code)
|
||||
assert "attribute access '.__bases__'" in violations
|
||||
assert "attribute access '.__subclasses__'" in violations
|
||||
|
||||
|
||||
def test_syntax_error_defers_to_manim():
|
||||
# A parse failure must not mask as a safety violation; Manim reports it.
|
||||
assert MathAnimate._scan_scene_code("class S(Scene):\n def construct(self)\n") == []
|
||||
|
||||
|
||||
def test_execute_blocks_dangerous_code_before_running_manim(monkeypatch):
|
||||
# Pretend manim is installed so execute() reaches the safety gate rather
|
||||
# than short-circuiting on a missing binary. The scan must reject before any
|
||||
# subprocess runs.
|
||||
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/manim")
|
||||
|
||||
def boom(*a, **k): # subprocess must never be reached
|
||||
raise AssertionError("subprocess.run should not be called for blocked code")
|
||||
|
||||
monkeypatch.setattr("subprocess.run", boom)
|
||||
|
||||
dangerous = (
|
||||
"from manim import *\n"
|
||||
"import os\n"
|
||||
"class S(Scene):\n"
|
||||
" def construct(self):\n"
|
||||
" print(os.environ)\n"
|
||||
)
|
||||
result = MathAnimate().execute({"scene_code": dangerous})
|
||||
assert result.success is False
|
||||
assert "safety scan" in result.error
|
||||
assert "allow_unsafe_code" in result.error
|
||||
|
||||
|
||||
def test_allow_unsafe_code_bypasses_scan(monkeypatch):
|
||||
# With the opt-out, execution proceeds past the scan to Manim (which we stub
|
||||
# to fail); the failure must NOT be the safety-scan message.
|
||||
monkeypatch.setattr("shutil.which", lambda _: "/usr/bin/manim")
|
||||
|
||||
class FakeProc:
|
||||
returncode = 1
|
||||
stderr = "manim ran"
|
||||
stdout = ""
|
||||
|
||||
monkeypatch.setattr("subprocess.run", lambda *a, **k: FakeProc())
|
||||
|
||||
dangerous = (
|
||||
"from manim import *\n"
|
||||
"import os\n"
|
||||
"class S(Scene):\n"
|
||||
" def construct(self):\n"
|
||||
" print(os.environ)\n"
|
||||
)
|
||||
result = MathAnimate().execute({"scene_code": dangerous, "allow_unsafe_code": True})
|
||||
assert result.success is False
|
||||
assert "safety scan" not in (result.error or "")
|
||||
Reference in New Issue
Block a user