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¶
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-serverversions0.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 viaextra_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, andctypes. 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.
Related¶
CodeSandboxBridgeGuard screens the same argument seam for
sandbox-escape host bridges (js.Deno, Deno.*, child_process), CVE-2026-120338.
Primary source¶
- NVD CVE-2026-44717 (2026-05-15)