diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f85da038..ddbec65b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ # AgentCore-specific dependencies (for inference_api) agentcore = [ "strands-agents==1.51.0", - "strands-agents-tools==0.8.6", + "strands-agents-tools==0.8.8", "aws-opentelemetry-distro==0.19.0", "bedrock-agentcore==1.21.0", diff --git a/backend/tests/security/test_calculator_sandbox.py b/backend/tests/security/test_calculator_sandbox.py new file mode 100644 index 00000000..00037acb --- /dev/null +++ b/backend/tests/security/test_calculator_sandbox.py @@ -0,0 +1,101 @@ +"""Regression tests for the sandbox in the vendored ``strands_tools.calculator``. + +``calculator`` is registered in ``create_default_registry()`` and seeded with +``enabledByDefault=True``, so it is reachable by every user on every agent and +it evaluates model-supplied expressions. Its AST allowlist is therefore a real +security boundary, not an input-validation nicety. + +The boundary permits a string literal only as a positional argument to a small +set of constructors that parse it as a plain name or numeric literal +(``Symbol``, ``symbols``, ``Rational``, ``Integer``, ``Float``); everywhere else +a string is rejected, which blocks the ``sympify``-backed re-parse escape. + +Up to ``strands-agents-tools`` 0.8.6 that check ignored the call's keyword +arguments, so ``symbols('...', cls=N)`` rerouted ``symbols`` to apply ``N`` — +and therefore ``sympify`` — to the string, re-parsing it outside the restricted +namespace. 0.8.8 trusts a string literal only when every keyword on the call is +a boolean assumption flag, and treats ``**kwargs`` unpacking as untrusted +because it can smuggle in ``cls``. + +These tests pin that behaviour to the installed wheel, so a downgrade or a +resolver drift back below 0.8.8 fails the suite instead of silently reopening +the escape. +""" + +from __future__ import annotations + +import pytest + +from strands_tools.calculator import _validate_expression_ast, parse_expression + +# --------------------------------------------------------------------------- +# The escape: a keyword that reroutes how the string argument is parsed. +# --------------------------------------------------------------------------- + +REROUTED_STRING_ARGS = [ + # The disclosed form: cls=N makes symbols apply N (sympify) to the string. + "symbols('x', cls=N)", + # The same reroute carrying a payload that must never reach sympify. + "symbols('__import__(\"os\").system(\"id\")', cls=N)", + # cls on the other string constructors is rejected for the same reason. + "Symbol('1+1', cls=N)", + # **kwargs unpacking can smuggle in cls, so it is untrusted too. + "symbols('x', **kw)", + # A non-boolean keyword is not an assumption flag. + "symbols('x', cls=Float)", +] + + +@pytest.mark.parametrize("expression", REROUTED_STRING_ARGS) +def test_string_arg_with_rerouting_keyword_rejected(expression: str) -> None: + """A string literal is not trusted when the call carries a rerouting keyword.""" + with pytest.raises(ValueError, match="string literals are not supported"): + parse_expression(expression) + + +def test_string_arg_outside_safe_constructors_rejected() -> None: + """The pre-existing half of the boundary: sympify-backed constructors stay closed.""" + with pytest.raises(ValueError, match="string literals are not supported"): + parse_expression("N('1+1')") + + +# --------------------------------------------------------------------------- +# The fix must not narrow legitimate use — calculator is on for every user. +# --------------------------------------------------------------------------- + +LEGITIMATE_EXPRESSIONS = [ + "2 + 2 * 10", + "x**2 + 2*x + 1", + "sin(pi/2) + log(E)", + "Symbol('x')", + "symbols('x y')", + "Rational('1/3')", + "Integer('42')", + "Float('3.14')", +] + + +@pytest.mark.parametrize("expression", LEGITIMATE_EXPRESSIONS) +def test_ordinary_expressions_still_parse(expression: str) -> None: + """Ordinary arithmetic and symbolic input are unaffected by the tightened check.""" + assert parse_expression(expression) is not None + + +ASSUMPTION_KEYWORD_CALLS = [ + "Symbol('x', positive=True)", + "Symbol('x', real=True, positive=True)", + "symbols('x y', positive=True)", +] + + +@pytest.mark.parametrize("expression", ASSUMPTION_KEYWORD_CALLS) +def test_assumption_keywords_remain_trusted(expression: str) -> None: + """Boolean assumption flags do not reroute parsing, so the string stays trusted. + + Asserted against the validator rather than ``parse_expression`` because these + calls fail further downstream for an unrelated, pre-existing reason: sympy's + ``implicit_multiplication_application`` transform rewrites ``positive=True`` + into a multiplication before ``parse_expr`` sees it. That behaviour is + identical on 0.8.6 and 0.8.8 — the security boundary is the layer under test. + """ + _validate_expression_ast(expression) diff --git a/backend/uv.lock b/backend/uv.lock index 5f2c7f6b..5f113bd9 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -125,7 +125,7 @@ requires-dist = [ { name = "starlette", specifier = "==1.3.1" }, { name = "strands-agents", marker = "extra == 'agentcore'", specifier = "==1.51.0" }, { name = "strands-agents", extras = ["bidi"], marker = "extra == 'bidi'", specifier = "==1.51.0" }, - { name = "strands-agents-tools", marker = "extra == 'agentcore'", specifier = "==0.8.6" }, + { name = "strands-agents-tools", marker = "extra == 'agentcore'", specifier = "==0.8.8" }, { name = "tiktoken", marker = "extra == 'dev'", specifier = "==0.12.0" }, { name = "trafilatura", specifier = "==2.0.0" }, { name = "types-aiofiles", marker = "extra == 'dev'", specifier = "==25.1.0.20260409" }, @@ -4715,7 +4715,7 @@ bidi = [ [[package]] name = "strands-agents-tools" -version = "0.8.6" +version = "0.8.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -4736,9 +4736,9 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/fc/63031f0d5483a036eb732ae52624e6429ed1385d9c68515763c323da00fd/strands_agents_tools-0.8.6.tar.gz", hash = "sha256:3cdafd706909be6e8cab5411067cdc5af269bec29f1903788629e3f092d9e8aa", size = 533539, upload-time = "2026-08-07T18:09:03.432Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e2/18f496ee1f3c17071228b76be67b50621d9f9061ee54c320bcb8b1683370/strands_agents_tools-0.8.8.tar.gz", hash = "sha256:7ec790d888ea5e24df038f40bcd52aa6ba82d86c578e5148f306c0969e5cbade", size = 539062, upload-time = "2026-09-04T14:58:21.668Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/32/36ff3b19528430d68f740bf61d6fe1f4afbf670f69a2ca204ae1f04139e2/strands_agents_tools-0.8.6-py3-none-any.whl", hash = "sha256:ed87ef1c405c635164e7e85e1cb0a1560bd30156b54b3c6cb11574e67d111d5c", size = 338989, upload-time = "2026-08-07T18:09:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4d/7680c2c620c7531eb063f2964804c763b580f1bfea5b948ef862b3031f29/strands_agents_tools-0.8.8-py3-none-any.whl", hash = "sha256:5e075462cdfc3cd96b31d5a3a552e24d5eacae42687e5378c1e5a469c7ef9d64", size = 339705, upload-time = "2026-09-04T14:58:19.783Z" }, ] [[package]]