From ffe3dfdf08e7e024043ea79b7e7cf6be3ab8abd6 Mon Sep 17 00:00:00 2001 From: Samuel Chen <119898205+sacchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:18:44 -0800 Subject: [PATCH 1/2] Expand SymPy surface with safe allowlist injection Add an explicit SymPy name allowlist and build extra locals through a filtered, non-overriding helper. Existing curated locals remain authoritative, and only public callable allowlist entries are imported.\n\nAdd tests for new function availability, collision protection for curated names, reserved-name assignment protection, and local-surface safety constraints. --- src/calc/core.py | 54 +++++++++++++++++++++++++++++++++++++++++++--- tests/test_core.py | 21 +++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/calc/core.py b/src/calc/core.py index 4881715..eb643ec 100644 --- a/src/calc/core.py +++ b/src/calc/core.py @@ -4,6 +4,7 @@ from difflib import get_close_matches from math import log +import sympy as _sympy from sympy import ( Abs, Add, @@ -50,7 +51,7 @@ function_exponentiation, implicit_multiplication_application, parse_expr, - rationalize, + rationalize, ) x, y, z, t = symbols("x y z t") @@ -131,6 +132,53 @@ def _den(expr): "S": Symbol, } +SYMPY_EXTRA_ALLOWLIST = ( + "S", + "acos", + "acosh", + "apart", + "asin", + "asinh", + "atan", + "atanh", + "binomial", + "cancel", + "collect", + "cosh", + "cot", + "csc", + "expand", + "factor", + "gamma", + "limit", + "powsimp", + "product", + "sec", + "series", + "sinh", + "summation", + "tanh", + "together", + "trigsimp", +) + + +def _expanded_sympy_locals() -> dict[str, object]: + expanded: dict[str, object] = {} + for name in SYMPY_EXTRA_ALLOWLIST: + if name in LOCALS_DICT: + continue + if not name.isidentifier() or name.startswith("_"): + continue + value = getattr(_sympy, name, None) + if value is None or not callable(value): + continue + expanded[name] = value + return expanded + + +LOCALS_DICT.update(_expanded_sympy_locals()) + # parse_expr internally uses eval. Keep globals minimal and disable builtins. GLOBAL_DICT = { @@ -145,14 +193,14 @@ def _den(expr): "factorial": factorial, } -TRANSFORMS = (auto_number, factorial_notation, convert_xor, function_exponentiation, rationalize, ) +TRANSFORMS = (auto_number, factorial_notation, convert_xor, function_exponentiation, rationalize) RELAXED_TRANSFORMS = ( auto_number, factorial_notation, convert_xor, function_exponentiation, implicit_multiplication_application, - rationalize, + rationalize, ) MAX_EXPRESSION_CHARS = 2000 BLOCKED_PATTERN = re.compile(r"(__|;|\n|\r)") diff --git a/tests/test_core.py b/tests/test_core.py index 4ea33e9..20816a7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,6 +1,7 @@ import pytest +from sympy import Symbol -from calc.core import evaluate, normalize_expression, reserved_name_suggestion +from calc.core import LOCALS_DICT, evaluate, normalize_expression, reserved_name_suggestion def test_exact_arithmetic(): @@ -135,6 +136,17 @@ def test_symbol_helpers_for_coefficient_workflows(): assert "C: 421/15" in out +def test_expanded_sympy_allowlist_functions_are_available(): + assert str(evaluate("binomial(5, 2)")) == "10" + assert str(evaluate("gamma(6)")) == "120" + assert str(evaluate("limit(sin(x)/x, x, 0)")) == "1" + + +def test_expanded_sympy_allowlist_does_not_override_curated_names(): + assert LOCALS_DICT["S"] is Symbol + assert str(evaluate('S("A")')) == "A" + + def test_numeric_eval(): assert str(evaluate("N(pi, 10)")) == "3.141592654" @@ -162,6 +174,13 @@ def test_assignment_and_ans_with_session_locals(): def test_assignment_rejects_reserved_name(): with pytest.raises(ValueError, match="reserved name"): evaluate("sin = 2", session_locals={}) + with pytest.raises(ValueError, match="reserved name"): + evaluate("gamma = 2", session_locals={}) + + +def test_expanded_sympy_allowlist_keeps_local_surface_safe(): + assert "__builtins__" not in LOCALS_DICT + assert all(not name.startswith("_") for name in LOCALS_DICT) def test_reserved_name_suggestion_prefers_close_session_name(): From a6863937fa2f86a7e98c589a8508eaa910f7c64e Mon Sep 17 00:00:00 2001 From: Samuel Chen <119898205+sacchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:32:25 -0800 Subject: [PATCH 2/2] Refine safe allowlist to include constants and atan2 Address review feedback by removing dead allowlist entries, adding atan2, and allowing explicitly allowlisted SymPy constants alongside callables.\n\nExtend tests to cover atan2 and constant I behavior, and document that expanded helper names are reserved for assignment. --- README.md | 1 + src/calc/core.py | 7 +++++-- tests/test_core.py | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 97d79be..f0c087a 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,7 @@ If you get stuck, run `:examples` or `:h`. - In ODE input, prefer explicit multiplication (`20*y` instead of `20y`) for predictable parsing. - Common LaTeX wrappers and commands are normalized: `$...$`, `\(...\)`, `\sin`, `\cos`, `\ln`, `\sqrt{...}`, `\frac{a}{b}` - `name = expr` assigns in REPL session (`ans` is always last result) +- Built-in helper names are reserved for evaluation (for example `sin`, `gamma`, `atan2`, `I`) and cannot be reassigned. - Undefined symbols raise an error ## Safety limits diff --git a/src/calc/core.py b/src/calc/core.py index eb643ec..2146bdd 100644 --- a/src/calc/core.py +++ b/src/calc/core.py @@ -133,13 +133,14 @@ def _den(expr): } SYMPY_EXTRA_ALLOWLIST = ( - "S", + "I", "acos", "acosh", "apart", "asin", "asinh", "atan", + "atan2", "atanh", "binomial", "cancel", @@ -171,7 +172,9 @@ def _expanded_sympy_locals() -> dict[str, object]: if not name.isidentifier() or name.startswith("_"): continue value = getattr(_sympy, name, None) - if value is None or not callable(value): + if value is None: + continue + if not (callable(value) or isinstance(value, _sympy.Basic)): continue expanded[name] = value return expanded diff --git a/tests/test_core.py b/tests/test_core.py index 20816a7..68bab0e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,5 +1,5 @@ import pytest -from sympy import Symbol +from sympy import I, Symbol from calc.core import LOCALS_DICT, evaluate, normalize_expression, reserved_name_suggestion @@ -140,11 +140,14 @@ def test_expanded_sympy_allowlist_functions_are_available(): assert str(evaluate("binomial(5, 2)")) == "10" assert str(evaluate("gamma(6)")) == "120" assert str(evaluate("limit(sin(x)/x, x, 0)")) == "1" + assert str(evaluate("atan2(1, 1)")) == "pi/4" + assert str(evaluate("I^2")) == "-1" def test_expanded_sympy_allowlist_does_not_override_curated_names(): assert LOCALS_DICT["S"] is Symbol assert str(evaluate('S("A")')) == "A" + assert LOCALS_DICT["I"] is I def test_numeric_eval(): @@ -176,6 +179,8 @@ def test_assignment_rejects_reserved_name(): evaluate("sin = 2", session_locals={}) with pytest.raises(ValueError, match="reserved name"): evaluate("gamma = 2", session_locals={}) + with pytest.raises(ValueError, match="reserved name"): + evaluate("I = 2", session_locals={}) def test_expanded_sympy_allowlist_keeps_local_surface_safe():