Skip to content

Eval-RCE guard (CVE-2026-44717, v0.8.0+)

agent_airlock.mcp_spec.eval_rce_guard.EvalRCEGuard is the runtime detector for the bare-eval RCE class disclosed by NVD on 2026-05-15 (CVE-2026-44717, MCP Calculate Server < 0.1.1).

Why

Per NVD CVE-2026-44717: "MCP Calculate Server is a mathematical calculation service based on MCP protocol and SymPy library. Prior to 0.1.1, the use of eval() to evaluate mathematical expressions without proper input sanitization leads to remote code execution."

The exploit class is not MCP-Calculate-specific. Any tool that passes a model-derived string to eval() / exec() / compile() / sympy.parsing.sympy_parser.parse_expr() without pinning local_dict / global_dict is vulnerable.

This guard is complementary to v0.7.5 FilterEvalRCEGuard:

Guard Targets
FilterEvalRCEGuard (v0.7.5) lambda / Expression.Lambda<> / {{ eval(...) }} syntax shapes in known filter fields
EvalRCEGuard (v0.8.0, this page) Bare eval( / exec( / compile( / __import__( / getattr( / parse_expr( invocations in any string arg, and since 0.10.32 a string that builds or fetches a Python code object (CodeType, FunctionType, LambdaType, __code__, co_code, gi_code, cr_code, ag_code, f_code, marshal.loads, and the routes below)

Install

Core. No optional extra. The sympy / mcp-calculate-server packages are not imported — the guard is a compiled-regex pass plus a tuple-membership check.

Quickstart

from agent_airlock import EvalRCEGuard, EvalRCEVerdict

guard = EvalRCEGuard()  # default sink set + curated vulnerable-package denylist

decision = guard.evaluate({"expression": "eval('__import__(\"os\").system(\"id\")')"})
# decision.allowed is False
# decision.verdict == EvalRCEVerdict.DENY_EVAL_SINK
# decision.matched_sink == "eval"

What's in the default sink set

frozenset({"eval", "exec", "compile", "__import__", "getattr", "parse_expr"})

The guard uses word-boundary regex so a benign string like user.name == 'Eval Industries' does NOT trigger a deny.

parse_expr safe-form exemption

parse_expr(s, local_dict={}) or parse_expr(s, global_dict={}) — the upstream-patched safe form from CVE-2026-44717 — is allowed. The detector inspects the same parenthesised arg list for either local_dict= or global_dict= keyword, outside a string literal, and every call in the string needs its own. Until 0.10.32 one pinned call anywhere exempted the whole string.

Code objects (CVE-2026-123783 class)

CVE-2026-123783 (Skyvern through 1.0.56): a script that calls no eval-family function can still run bytecode, by rebuilding a function from a code object. This guard refuses a string that builds or fetches one. It is on by default, and extra_sinks, which only adds, cannot turn it off. The refusal is DENY_EVAL_SINK with matched_sink == "code_object", so a caller that handles that verdict refuses it too.

Route Refused when a string holds
The dunder __code__ anywhere, quoted or not
An attribute .co_code, .gi_code, .cr_code, .ag_code, .f_code, .codeobj (dis.Bytecode)
The types CodeType, FunctionType, LambdaType as whole words, called or not, so an alias is caught at the line that takes it
replace .replace(co_code=...), or any other co_* keyword
marshal marshal.loads, marshal.load, import marshal, from marshal import, a quoted "marshal"
Compilers not spelled compile( codeop, compile_command, source_to_code, _compile_bytecode, _get_code_object
Walkers gc.get_referents, inspect.getmembers (and an aliased inspect import), getmembers_static, iscode, sys.monitoring

Identifiers are read the way Python reads them (NFKC, PEP 3131), so f.__code__ in fullwidth letters is refused, and so is exec(...), which the sink patterns missed until 0.10.32.

CodeType and FunctionType are matched whole wherever they appear, as Skyvern's fix does. A string argument holding JSON with a "CodeType" key, ordinary in billing data, is refused too, so attach this guard to tools that take code.

Vulnerable-package denylist

The guard ships with a curated denylist of (package_name, version) tuples drawn from public advisories. Today's seed:

  • mcp-calculate-server versions 0.0.8, 0.0.9, 0.1.0

When a tool arg carries server_package + server_version matching any entry, the guard returns DENY_VULNERABLE_PACKAGE. Operators extend via extra_vulnerable_packages=(("name", "version"), ...).

Companion preset

agent_airlock.policy_presets.stdio_guard_eval_defaults_2026_05_15() returns the recommended config dict — parity with v0.7.5's semantic_kernel_filter_eval_rce_2026_25592_26030_defaults.

Decision shape

evaluate(args) returns EvalRCEDecision. The allowed: bool field mirrors the v0.6.1–v0.7.x decision family for chain-friendly composition.

Verdict When
ALLOW no eval-sink and no vulnerable-package match
DENY_EVAL_SINK bare-eval sink invocation detected in a string arg, at any depth inside lists, tuples, sets and mappings (since 0.10.24), or a string that builds or fetches a Python code object (since 0.10.32, matched_sink == "code_object")
DENY_VULNERABLE_PACKAGE server_package + server_version matches the denylist
DENY_UNINSPECTABLE an argument nested more than 32 levels deep, or a container that contains itself (since 0.10.24); bytes in an inspected field that are not valid UTF-8 (since 0.10.28; they were decoded with replacement characters, which could hide a match from the patterns)

Honest scope

  • Regex heuristic. Catches the disclosed CVE class and obvious obfuscation variants (namespace prefix like sympy.parse_expr, whitespace before (). Does NOT catch an attacker who controls the surrounding context and hides the sink behind an indirection (locals()["eval"](...)). That variant is named explicitly via extra_sinks=frozenset({"locals", ...}).
  • A list of names has the limit that made CVE-2026-123783. A string that assembles a name at runtime through something other than getattr( still passes the code-object screen: f.__getattribute__("__co" + "de__"). So does a module that runs source text without naming a sink (timeit, runpy, pdb, code.InteractiveInterpreter), loader.get_code, and ctypes. The defence for those is isolation: run the code in a sandbox backend, and treat this guard as the check at the argument.
  • Vulnerable-package list is curated. Operators are responsible for keeping the denylist current as new CVEs in this class drop.

CodeSandboxBridgeGuard screens the same argument seam for sandbox-escape host bridges (js.Deno, Deno.*, child_process), CVE-2026-120338.

Primary source