From 54d0ea1f698475d032e010ade318dad1fbdd2067 Mon Sep 17 00:00:00 2001 From: Rohan Date: Sat, 3 Oct 2026 22:42:30 +0530 Subject: [PATCH] fix(symbolic): prevent Python execution in expression parsing --- docs/symbolic/index.md | 40 ++- packages/gds-domains/README.md | 9 + .../gds_domains/symbolic/_expressions.py | 167 +++++++++++ .../gds_domains/symbolic/compile.py | 16 +- .../gds_domains/symbolic/elements.py | 6 +- .../gds_domains/symbolic/hamiltonian.py | 22 +- .../gds_domains/symbolic/linearize.py | 15 +- .../symbolic/test_expression_security.py | 273 ++++++++++++++++++ packages/gds-symbolic/CLAUDE.md | 9 +- packages/gds-symbolic/README.md | 9 + 10 files changed, 532 insertions(+), 34 deletions(-) create mode 100644 packages/gds-domains/gds_domains/symbolic/_expressions.py create mode 100644 packages/gds-domains/tests/symbolic/test_expression_security.py diff --git a/docs/symbolic/index.md b/docs/symbolic/index.md index 044b07f..6f39aa4 100644 --- a/docs/symbolic/index.md +++ b/docs/symbolic/index.md @@ -21,7 +21,7 @@ - **`OutputEquation`** -- symbolic expression for sensor output `y` (e.g., `"x + noise"`) - **`compile_to_ode()`** -- lambdifies symbolic equations into a callable `ODEFunction` compatible with `gds-continuous` - **`linearize()`** -- computes Jacobian matrices (A, B, C, D) at an operating point -- **Safe expression parsing** -- uses `sympy.parsing.sympy_parser.parse_expr`, never `eval` +- **Restricted expression grammar** -- constructs SymPy expressions directly from validated syntax ## When to Use It @@ -62,10 +62,10 @@ gds-control (pip install gds-domains) Symbolic expressions (strings) | v -parse_expr() --> SymPy Expr objects +Validated AST --> SymPy Expr objects | v -compile_to_ode() --> ODEFunction (lambdified, numpy-backed) +compile_to_ode() --> ODEFunction (lambdified, math-backed) | | v v linearize() gds-continuous ODEModel @@ -74,7 +74,39 @@ linearize() gds-continuous ODEModel LinearSystem(A, B, C, D) --> eigenvalue analysis, controllability, etc. ``` -All expression parsing uses `sympy.parsing.sympy_parser.parse_expr` with a restricted transformation set -- arbitrary code execution is not possible. +## Expression grammar and security + +ODE compilation, linearization (including output equations), and Hamiltonian +state dynamics and Lagrangian parsing share a restricted expression grammar: + +- Integer and finite floating-point literals, including scientific notation. +- Declared state, input, and parameter names; Hamiltonian expressions also + support `t` and generated costate names. +- Constants `pi` and `E`, unless a declared variable uses the same name. +- Parentheses, `+`, `-`, `*`, `/`, `**`, and unary `+`/`-`. +- One-argument functions `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `sinh`, + `cosh`, `tanh`, `exp`, `sqrt`, and `Abs`; `log` takes one argument or an + optional second argument for the base. + +Use explicit multiplication (`2*x`) and `**` for powers. Unknown symbols, +SymPy constructors such as `Symbol(...)`, attribute access, indexing, Python +statements, arbitrary calls, keyword arguments, and argument unpacking raise +`SymbolicError` when the expressions are consumed. Model construction performs +structural validation; it does not parse expressions. `terminal_cost` is currently +stored but not consumed by Hamiltonian derivation. + +The parser validates the complete Python AST and constructs SymPy objects directly; +it does not evaluate the source string. Symbol names must be ASCII Python +identifiers, excluding keywords and names starting with `__`. Code generation uses +`lambdify` with dummy arguments and internally constructed expressions. +`lambdify` itself generates and executes Python code, so arbitrary externally +supplied SymPy objects are outside this expression-string security boundary. + +Expressions are limited to 4,096 characters, 512 AST nodes, depth 64, and integer +literals of at most 256 bits. Constant simplification is deferred during parsing. +These checks do not bound all subsequent symbolic differentiation, simplification, +or numerical work. Services processing hostile input should enforce time and +memory limits in an isolated worker. ## Quick Start diff --git a/packages/gds-domains/README.md b/packages/gds-domains/README.md index fee85ba..a6d1a8c 100644 --- a/packages/gds-domains/README.md +++ b/packages/gds-domains/README.md @@ -20,3 +20,12 @@ pip install gds-domains[symbolic] # + SymPy pip install gds-domains[nashpy] # + Nash equilibrium pip install gds-domains[all] # everything ``` + +## Symbolic expressions + +Symbolic compilation and analysis accept a restricted mathematical grammar: +declared variables, numeric literals, arithmetic, and approved math functions. +Unsupported syntax raises `SymbolicError` when an expression is consumed. +See the [expression grammar and security limits](https://dynamicalsystemsgroup.github.io/gds-core/symbolic/#expression-grammar-and-security) +for supported functions, symbol naming rules, and resource limits. Parsing limits +do not bound all subsequent symbolic computations. diff --git a/packages/gds-domains/gds_domains/symbolic/_expressions.py b/packages/gds-domains/gds_domains/symbolic/_expressions.py new file mode 100644 index 0000000..dcf838b --- /dev/null +++ b/packages/gds-domains/gds_domains/symbolic/_expressions.py @@ -0,0 +1,167 @@ +"""Translate a bounded mathematical grammar to SymPy without evaluating Python.""" + +from __future__ import annotations + +import ast +import keyword +import math +from typing import Any + +from gds_domains.symbolic._compat import require_sympy +from gds_domains.symbolic.errors import SymbolicError + +MAX_LENGTH = 4096 +MAX_NODES = 512 +MAX_DEPTH = 64 +MAX_INTEGER_BITS = 256 + +# Explicit arities; no dynamic lookup of names on the SymPy module. +_ARITIES = { + "sin": (1,), + "cos": (1,), + "tan": (1,), + "asin": (1,), + "acos": (1,), + "atan": (1,), + "sinh": (1,), + "cosh": (1,), + "tanh": (1,), + "exp": (1,), + "log": (1, 2), + "sqrt": (1,), + "Abs": (1,), +} + + +def make_symbols(names: list[str]) -> dict[str, Any]: + """Create symbols whose names can safely pass through code generation.""" + require_sympy() + import sympy + + for name in names: + if ( + not name.isascii() + or not name.isidentifier() + or keyword.iskeyword(name) + or name.startswith("__") + ): + raise SymbolicError( + "Symbol names must be ASCII Python identifiers without " + "keywords or a leading double underscore" + ) + return {name: sympy.Symbol(name) for name in names} + + +def parse_expression(source: str, symbols: dict[str, Any]) -> Any: + """Parse only numbers, declared names, arithmetic and approved math calls. + + Validate the entire tree before constructing any SymPy objects. Constructors + use evaluate=False to defer potentially expensive constant simplification. + These bounds do not guarantee limits on subsequent symbolic computations. + """ + if len(source) > MAX_LENGTH: + raise SymbolicError(f"Expression exceeds {MAX_LENGTH} characters") + try: + tree = ast.parse(source.strip(), mode="eval") + except (SyntaxError, ValueError, RecursionError) as exc: + raise SymbolicError("Invalid mathematical expression syntax") from exc + if sum(1 for _ in ast.walk(tree)) > MAX_NODES: + raise SymbolicError(f"Expression exceeds {MAX_NODES} syntax nodes") + + def validate(node: ast.AST, depth: int = 0) -> None: + if depth > MAX_DEPTH: + raise SymbolicError(f"Expression exceeds nesting depth {MAX_DEPTH}") + if isinstance(node, ast.Constant): + value = node.value + if type(value) is int: + if value.bit_length() > MAX_INTEGER_BITS: + raise SymbolicError("Integer literal exceeds 256 bits") + elif type(value) is float: + if not math.isfinite(value): + raise SymbolicError("Numeric literals must be finite") + else: + raise SymbolicError("Only real numeric literals are supported") + elif isinstance(node, ast.Name): + if node.id not in symbols and node.id not in {"pi", "E"}: + raise SymbolicError(f"Undeclared symbol: {node.id}") + elif isinstance(node, ast.UnaryOp) and isinstance( + node.op, (ast.UAdd, ast.USub) + ): + validate(node.operand, depth + 1) + elif isinstance(node, ast.BinOp) and isinstance( + node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow) + ): + validate(node.left, depth + 1) + validate(node.right, depth + 1) + elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if node.func.id not in _ARITIES: + raise SymbolicError(f"Unsupported math function: {node.func.id}") + if node.keywords or len(node.args) not in _ARITIES[node.func.id]: + raise SymbolicError(f"Invalid arguments for {node.func.id}") + for arg in node.args: + validate(arg, depth + 1) + else: + raise SymbolicError(f"Unsupported expression syntax: {type(node).__name__}") + + validate(tree.body) + require_sympy() + import sympy + + functions = { + "sin": sympy.sin, + "cos": sympy.cos, + "tan": sympy.tan, + "asin": sympy.asin, + "acos": sympy.acos, + "atan": sympy.atan, + "sinh": sympy.sinh, + "cosh": sympy.cosh, + "tanh": sympy.tanh, + "exp": sympy.exp, + "log": sympy.log, + "sqrt": sympy.sqrt, + "Abs": sympy.Abs, + } + constants = {"pi": sympy.pi, "E": sympy.E} + + def build(node: ast.AST) -> Any: + if isinstance(node, ast.Constant): + return ( + sympy.Integer(node.value) + if type(node.value) is int + else sympy.Float(node.value) + ) + if isinstance(node, ast.Name): + return symbols[node.id] if node.id in symbols else constants[node.id] + if isinstance(node, ast.UnaryOp): + value = build(node.operand) + return ( + value + if isinstance(node.op, ast.UAdd) + else sympy.Mul(-1, value, evaluate=False) + ) + if isinstance(node, ast.BinOp): + left, right = build(node.left), build(node.right) + if isinstance(node.op, ast.Add): + return sympy.Add(left, right, evaluate=False) + if isinstance(node.op, ast.Sub): + return sympy.Add( + left, sympy.Mul(-1, right, evaluate=False), evaluate=False + ) + if isinstance(node.op, ast.Mult): + return sympy.Mul(left, right, evaluate=False) + if isinstance(node.op, ast.Div): + return sympy.Mul( + left, sympy.Pow(right, -1, evaluate=False), evaluate=False + ) + return sympy.Pow(left, right, evaluate=False) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + return functions[node.func.id]( + *(build(arg) for arg in node.args), evaluate=False + ) + raise AssertionError("Expression tree was not validated") + + try: + return build(tree.body) + except (TypeError, ValueError, OverflowError, RecursionError) as exc: + raise SymbolicError("Unable to construct mathematical expression") from exc diff --git a/packages/gds-domains/gds_domains/symbolic/compile.py b/packages/gds-domains/gds_domains/symbolic/compile.py index b265761..5569ac6 100644 --- a/packages/gds-domains/gds_domains/symbolic/compile.py +++ b/packages/gds-domains/gds_domains/symbolic/compile.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any from gds_domains.symbolic._compat import require_sympy +from gds_domains.symbolic._expressions import make_symbols, parse_expression if TYPE_CHECKING: from gds_continuous.types import ODEFunction @@ -31,18 +32,17 @@ def compile_to_ode( input_names = [i.name for i in model.inputs] # Build symbol table - state_syms = {name: sympy.Symbol(name) for name in state_order} - input_syms = {name: sympy.Symbol(name) for name in input_names} - param_syms = {name: sympy.Symbol(name) for name in model.symbolic_params} + state_syms = make_symbols(state_order) + input_syms = make_symbols(input_names) + param_syms = make_symbols(model.symbolic_params) all_syms = {**state_syms, **input_syms, **param_syms} - # Parse expressions using safe parser (no eval, no builtins) - from sympy.parsing.sympy_parser import parse_expr + # Construct expressions from the restricted mathematical grammar. eq_map: dict[str, Any] = {} for eq in model.state_equations: - expr = parse_expr(eq.expr_str, local_dict=all_syms) + expr = parse_expression(eq.expr_str, all_syms) eq_map[eq.state_name] = expr # Build ordered RHS vector @@ -60,7 +60,9 @@ def compile_to_ode( + [input_syms[n] for n in input_names] + [param_syms[n] for n in model.symbolic_params] ) - rhs_lambda = sympy.lambdify(ordered_symbols, rhs_exprs, modules="math") + rhs_lambda = sympy.lambdify( + ordered_symbols, rhs_exprs, modules="math", dummify=True + ) n_states = len(state_order) diff --git a/packages/gds-domains/gds_domains/symbolic/elements.py b/packages/gds-domains/gds_domains/symbolic/elements.py index 14972f4..4304df2 100644 --- a/packages/gds-domains/gds_domains/symbolic/elements.py +++ b/packages/gds-domains/gds_domains/symbolic/elements.py @@ -8,11 +8,11 @@ class StateEquation(BaseModel, frozen=True): """Symbolic ODE right-hand side for a single state variable. - Declares: dx_i/dt = expr, where ``expr_str`` is a SymPy-parseable - string (e.g. ``"-k*x + u"``). + Declares: dx_i/dt = expr, where ``expr_str`` is a string in the + restricted mathematical grammar (e.g. ``"-k*x + u"``). The string form is R1-serializable. The sympy.Expr object is R3 — - reconstructed at lambdify time via ``sympy.sympify``. + constructed from validated syntax when the expression is consumed. """ state_name: str diff --git a/packages/gds-domains/gds_domains/symbolic/hamiltonian.py b/packages/gds-domains/gds_domains/symbolic/hamiltonian.py index e6517bc..b12b6c6 100644 --- a/packages/gds-domains/gds_domains/symbolic/hamiltonian.py +++ b/packages/gds-domains/gds_domains/symbolic/hamiltonian.py @@ -17,6 +17,7 @@ from pydantic import BaseModel from gds_domains.symbolic._compat import require_sympy +from gds_domains.symbolic._expressions import make_symbols, parse_expression class HamiltonianSpec(BaseModel, frozen=True): @@ -25,9 +26,9 @@ class HamiltonianSpec(BaseModel, frozen=True): Parameters ---------- lagrangian - Running cost L(x, u, t) as a SymPy-parseable string. + Running cost L(x, u, t) using the restricted mathematical expression grammar. terminal_cost - Terminal cost Phi(x(T)) as a SymPy-parseable string. + Terminal cost Phi(x(T)) using the restricted mathematical expression grammar. Empty string means no terminal cost. control_bounds Lower and upper bounds for each control input. @@ -100,14 +101,13 @@ def derive_hamiltonian( """ require_sympy() import sympy - from sympy.parsing.sympy_parser import parse_expr # Build symbol tables - state_syms = {n: sympy.Symbol(n) for n in state_names} + state_syms = make_symbols(state_names) costate_names = [f"p_{n}" for n in state_names] - costate_syms = {n: sympy.Symbol(n) for n in costate_names} - input_syms = {n: sympy.Symbol(n) for n in input_names} - param_syms = {n: sympy.Symbol(n) for n in param_names} + costate_syms = make_symbols(costate_names) + input_syms = make_symbols(input_names) + param_syms = make_symbols(param_names) t_sym = sympy.Symbol("t") all_syms = { @@ -122,12 +122,12 @@ def derive_hamiltonian( f_exprs = {} for name in state_names: if name in state_equations: - f_exprs[name] = parse_expr(state_equations[name], local_dict=all_syms) + f_exprs[name] = parse_expression(state_equations[name], all_syms) else: f_exprs[name] = sympy.Integer(0) # Parse Lagrangian L(x, u, t) - lagrangian = parse_expr(spec.lagrangian, local_dict=all_syms) + lagrangian = parse_expression(spec.lagrangian, all_syms) # Hamiltonian: H = L + p^T f hamiltonian = lagrangian @@ -156,7 +156,9 @@ def derive_hamiltonian( + [input_syms[n] for n in input_names] + [param_syms[n] for n in param_names] ) - rhs_lambda = sympy.lambdify(ordered_symbols, rhs_exprs, modules="math") + rhs_lambda = sympy.lambdify( + ordered_symbols, rhs_exprs, modules="math", dummify=True + ) def augmented_ode(t: float, y: list[float], params: dict[str, Any]) -> list[float]: input_vals = [params.get(n, 0.0) for n in input_names] diff --git a/packages/gds-domains/gds_domains/symbolic/linearize.py b/packages/gds-domains/gds_domains/symbolic/linearize.py index eae47e3..dcd1622 100644 --- a/packages/gds-domains/gds_domains/symbolic/linearize.py +++ b/packages/gds-domains/gds_domains/symbolic/linearize.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any from gds_domains.symbolic._compat import require_sympy +from gds_domains.symbolic._expressions import make_symbols, parse_expression if TYPE_CHECKING: from gds_domains.symbolic.model import SymbolicControlModel @@ -53,16 +54,15 @@ def linearize( any unspecified parameter. """ require_sympy() - import sympy param_values = param_values or {} state_names = [s.name for s in model.states] input_names = [i.name for i in model.inputs] - state_syms = {name: sympy.Symbol(name) for name in state_names} - input_syms = {name: sympy.Symbol(name) for name in input_names} - param_syms = {name: sympy.Symbol(name) for name in model.symbolic_params} + state_syms = make_symbols(state_names) + input_syms = make_symbols(input_names) + param_syms = make_symbols(model.symbolic_params) all_syms: dict[str, Any] = {**state_syms, **input_syms, **param_syms} @@ -75,12 +75,11 @@ def linearize( for name in model.symbolic_params: subs[param_syms[name]] = param_values.get(name, 0.0) - # Parse state equations using safe parser (no eval, no builtins) - from sympy.parsing.sympy_parser import parse_expr + # Construct state expressions from the restricted mathematical grammar. eq_map: dict[str, Any] = {} for eq in model.state_equations: - eq_map[eq.state_name] = parse_expr(eq.expr_str, local_dict=all_syms) + eq_map[eq.state_name] = parse_expression(eq.expr_str, all_syms) # A matrix: df_i/dx_j A = _jacobian(eq_map, state_names, state_syms, subs) @@ -92,7 +91,7 @@ def linearize( out_map: dict[str, Any] = {} output_names: list[str] = [] for eq in model.output_equations: - out_map[eq.sensor_name] = parse_expr(eq.expr_str, local_dict=all_syms) + out_map[eq.sensor_name] = parse_expression(eq.expr_str, all_syms) output_names.append(eq.sensor_name) # C matrix: dh_i/dx_j diff --git a/packages/gds-domains/tests/symbolic/test_expression_security.py b/packages/gds-domains/tests/symbolic/test_expression_security.py new file mode 100644 index 0000000..c7e9f60 --- /dev/null +++ b/packages/gds-domains/tests/symbolic/test_expression_security.py @@ -0,0 +1,273 @@ +"""Exercise expression security through every public parsing entry point.""" + +from __future__ import annotations + +import math +import os + +import pytest + +pytest.importorskip("sympy") + +from gds_domains.control import Input, Sensor, State +from gds_domains.symbolic import ( + OutputEquation, + StateEquation, + SymbolicControlModel, + SymbolicError, +) +from gds_domains.symbolic.hamiltonian import ( + HamiltonianSpec, + derive_hamiltonian, +) + + +def exercise(entry: str, expression: str) -> None: + model = SymbolicControlModel( + name="security", + states=[State(name="x")], + sensors=[Sensor(name="y", observes=["x"])], + state_equations=[ + StateEquation( + state_name="x", expr_str="x" if entry == "output" else expression + ) + ], + output_equations=[OutputEquation(sensor_name="y", expr_str=expression)] + if entry == "output" + else [], + ) + if entry == "compile": + model.to_ode_function() + elif entry in {"state", "output"}: + model.linearize([0.0], []) + else: + derive_hamiltonian( + {"x": expression if entry == "dynamics" else "x"}, + ["x"], + [], + [], + HamiltonianSpec(lagrangian=expression if entry == "lagrangian" else "0"), + ) + + +@pytest.mark.parametrize( + "entry", ["compile", "state", "output", "dynamics", "lagrangian"] +) +@pytest.mark.parametrize( + "expression", + [ + "__import__('os').getpid()", + "(__import__('os').getpid(), x)[1]", + "sin(__import__('os').getpid())", + "x + (__import__('os').getpid())", + "(x := __import__('os').getpid())", + "x + unknown", + "Symbol('unknown')", + "x.__class__", + "(lambda: x)()", + "[x for x in [1]][0]", + "sin(x, evaluate=True)", + "sin(*[x])", + "sin()", + "log(x, 2, 3)", + "x // 2", + "x % 2", + "x ^ 2", + "x if x else 0", + "True", + "1j", + "1e999", + "'x'", + "", + "x; x", + ], +) +def test_rejects_untrusted_expressions_without_execution( + entry: str, expression: str, monkeypatch: pytest.MonkeyPatch +) -> None: + calls = [] + + def getpid() -> int: + calls.append(True) + return 123 + + monkeypatch.setattr(os, "getpid", getpid) + try: + with pytest.raises(SymbolicError): + exercise(entry, expression) + finally: + assert calls == [], "Expression executed Python before being rejected" + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ("sin(x)", math.sin(0.5)), + ("cos(x)", math.cos(0.5)), + ("tan(x)", math.tan(0.5)), + ("asin(x)", math.asin(0.5)), + ("acos(x)", math.acos(0.5)), + ("atan(x)", math.atan(0.5)), + ("sinh(x)", math.sinh(0.5)), + ("cosh(x)", math.cosh(0.5)), + ("tanh(x)", math.tanh(0.5)), + ("exp(x)", math.exp(0.5)), + ("log(x)", math.log(0.5)), + ("log(x, 2)", math.log(0.5, 2)), + ("sqrt(x)", math.sqrt(0.5)), + ("Abs(-x)", 0.5), + ("pi + E", math.pi + math.e), + (" +x - 2*x / (1 + x**2) + 1e-3 ", 0.5 - 1 / 1.25 + 0.001), + ("1/2", 0.5), + ], +) +def test_supported_math_compiles(expression: str, expected: float) -> None: + model = SymbolicControlModel( + name="math", + states=[State(name="x")], + state_equations=[StateEquation(state_name="x", expr_str=expression)], + ) + fn, _ = model.to_ode_function() + assert fn(0.0, [0.5], {}) == [pytest.approx(expected)] + + +def test_functions_support_symbolic_differentiation() -> None: + model = SymbolicControlModel( + name="math", + states=[State(name="x")], + sensors=[Sensor(name="y", observes=["x"])], + state_equations=[StateEquation(state_name="x", expr_str="sin(x)")], + output_equations=[OutputEquation(sensor_name="y", expr_str="exp(x)")], + ) + lin = model.linearize([0.0], []) + assert lin.A == [[1.0]] + assert lin.C == [[1.0]] + system = derive_hamiltonian( + {"x": "sin(x)"}, ["x"], [], [], HamiltonianSpec(lagrangian="x**2") + ) + assert system.augmented_ode(0.0, [0.0, 2.0], {}) == [0.0, -2.0] + + +@pytest.mark.parametrize( + "entry", ["compile", "state", "output", "dynamics", "lagrangian"] +) +@pytest.mark.parametrize( + "expression", + ["x" * 4097, "+".join(["x"] * 200), "-" * 66 + "x", str(2**256)], +) +def test_expression_limits(entry: str, expression: str) -> None: + with pytest.raises(SymbolicError): + exercise(entry, expression) + + +@pytest.mark.parametrize( + "name", ["x); injected() #", "__builtins__", "lambda", "\u03b1"] +) +@pytest.mark.parametrize("role", ["state", "input", "parameter"]) +def test_rejects_unsafe_symbol_names(name: str, role: str) -> None: + state_names = [name] if role == "state" else ["x"] + input_names = [name] if role == "input" else [] + param_names = [name] if role == "parameter" else [] + model = SymbolicControlModel( + name="symbols", + states=[State(name=n) for n in state_names], + inputs=[Input(name=n) for n in input_names], + symbolic_params=param_names, + state_equations=[StateEquation(state_name=state_names[0], expr_str="1")], + ) + with pytest.raises(SymbolicError): + model.to_ode_function() + with pytest.raises(SymbolicError): + model.linearize([0.0], [0.0] * len(input_names)) + with pytest.raises(SymbolicError): + derive_hamiltonian( + {state_names[0]: "1"}, + state_names, + input_names, + param_names, + HamiltonianSpec(lagrangian="0"), + ) + + +@pytest.mark.parametrize("entry", ["compile", "linearize"]) +def test_json_loaded_model_rejects_payload_without_execution( + entry: str, monkeypatch: pytest.MonkeyPatch +) -> None: + calls = [] + + def getpid() -> int: + calls.append(True) + return 123 + + monkeypatch.setattr(os, "getpid", getpid) + model = SymbolicControlModel.model_validate_json( + '{"name":"loaded","states":[{"name":"x"}],' + '"state_equations":[{"state_name":"x",' + '"expr_str":"__import__(\'os\').getpid()"}]}' + ) + try: + with pytest.raises(SymbolicError): + if entry == "compile": + model.to_ode_function() + else: + model.linearize([0.0], []) + finally: + assert calls == [], "Loading or processing the model executed Python" + + +@pytest.mark.parametrize( + ("expression", "expected"), + [ + ("x" + " " * 4095, 0.5), + ("+".join(["(x+x)"] * 64), 64.0), + ("-" * 64 + "x", 0.5), + (str(2**255), float(2**255)), + ], +) +def test_valid_expressions_near_limits(expression: str, expected: float) -> None: + model = SymbolicControlModel( + name="limits", + states=[State(name="x")], + state_equations=[StateEquation(state_name="x", expr_str=expression)], + ) + fn, _ = model.to_ode_function() + assert fn(0.0, [0.5], {}) == [pytest.approx(expected)] + + +@pytest.mark.parametrize( + "entry", ["compile", "state", "output", "dynamics", "lagrangian"] +) +def test_expression_paths_do_not_use_python_evaluating_parser( + entry: str, monkeypatch: pytest.MonkeyPatch +) -> None: + from sympy.parsing import sympy_parser + + def unsafe_parser(*args, **kwargs): + raise AssertionError("Expression path used SymPy's Python-evaluating parser") + + monkeypatch.setattr(sympy_parser, "parse_expr", unsafe_parser) + exercise(entry, "sin(x) + x**2") + + +def test_symbol_names_cannot_shadow_generated_math_calls() -> None: + model = SymbolicControlModel( + name="symbols", + states=[State(name="sin")], + symbolic_params=["pi"], + state_equations=[StateEquation(state_name="sin", expr_str="sin(sin) + pi")], + ) + fn, _ = model.to_ode_function() + assert fn(0.0, [0.5], {"pi": 2.0}) == [pytest.approx(math.sin(0.5) + 2.0)] + + +def test_compatibility_wrapper_rejects_payload() -> None: + legacy = pytest.importorskip("gds_symbolic") + model = legacy.SymbolicControlModel( + name="legacy", + states=[State(name="x")], + state_equations=[ + legacy.StateEquation(state_name="x", expr_str="__import__('os').getpid()") + ], + ) + with pytest.raises(SymbolicError): + model.to_ode_function() diff --git a/packages/gds-symbolic/CLAUDE.md b/packages/gds-symbolic/CLAUDE.md index 3341a8f..9008645 100644 --- a/packages/gds-symbolic/CLAUDE.md +++ b/packages/gds-symbolic/CLAUDE.md @@ -14,7 +14,7 @@ |--------|---------| | `elements.py` | `StateEquation`, `OutputEquation` — frozen Pydantic models storing `expr_str` (R1-serializable) | | `model.py` | `SymbolicControlModel(ControlModel)` — adds symbolic equations + validation | -| `compile.py` | `compile_to_ode(model)` → `(ODEFunction, state_order)` via `parse_expr` + `lambdify` | +| `compile.py` | `compile_to_ode(model)` → `(ODEFunction, state_order)` via validated AST construction + `lambdify` | | `linearize.py` | `linearize(model, x0, u0)` → `LinearizedSystem(A, B, C, D)` via Jacobians | | `transfer.py` | `ss_to_tf()`, `poles()`, `zeros()`, `controllability_matrix()`, `sensitivity()` (Gang of Six) | | `delay.py` | `pade_approximation()`, `delay_system()` — Padé time delay modeling | @@ -22,7 +22,12 @@ ### Security -Expression parsing uses `sympy.parsing.sympy_parser.parse_expr` with a restricted `local_dict` — NOT `sympify` (which uses `eval`). This is safe for untrusted `expr_str` input. +Use the shared parser in `gds_domains.symbolic._expressions` for expression +strings. It validates an allowlist of AST nodes, names and math calls, then builds +SymPy expressions directly. Symbol creation uses `make_symbols`; numerical code +generation uses `lambdify(..., dummify=True)`. Keep all expression entry points on +this path. The grammar and computational limits are documented in +`docs/symbolic/index.md`; parsing limits do not bound subsequent symbolic work. ### R1/R2/R3 boundary diff --git a/packages/gds-symbolic/README.md b/packages/gds-symbolic/README.md index e30bea8..c6acca4 100644 --- a/packages/gds-symbolic/README.md +++ b/packages/gds-symbolic/README.md @@ -35,3 +35,12 @@ ode_fn, state_order = model.to_ode_function() lin = model.linearize(x0=[0.0, 0.0], u0=[0.0]) print(lin.A) # [[0, 1], [-k, -c]] evaluated at operating point ``` + +## Symbolic expressions + +Symbolic compilation and analysis accept a restricted mathematical grammar: +declared variables, numeric literals, arithmetic, and approved math functions. +Unsupported syntax raises `SymbolicError` when an expression is consumed. +See the [expression grammar and security limits](https://dynamicalsystemsgroup.github.io/gds-core/symbolic/#expression-grammar-and-security) +for supported functions, symbol naming rules, and resource limits. Parsing limits +do not bound all subsequent symbolic computations.