Blog

CVE-2026-35002: Agno's tool-schema eval() and how mvm-scout caught it

A differential mvm-scout run isolates the exact eval() call behind Agno's critical RCE (CVE-2026-35002) and confirms the fix silences it — detection evidence only, no containment claim.

securitycve-teardownsupply-chainstatic-analysispython
A diagram showing the attack chain from an untrusted field_type string through Python's eval() to arbitrary code execution in the Agno process.

The bug

Agno versions before 2.3.24 pass a user-controlled field_type string straight into Python’s eval() when reconstructing tool schemas, letting arbitrary Python code run wherever that data originates. It’s tracked as CVE-2026-35002, CVSS 9.3, GitHub severity Critical, fixed in 2.3.24.

The interesting part isn’t the CVSS score. It’s how ordinary the mistake looks in context, and how cleanly a differential scan isolates it down to one line.

The mechanism

In libs/agno/agno/tools/function.py, line 59, the field_type value from an incoming data dict is handed to eval() to resolve it to a Python type:

field_type=eval(data["field_type"]), # Convert string type name to actual type

Agno’s tool-schema deserialization took a shortcut that is easy to overlook: converting a type-name string back into a real Python type by calling eval() on it. When that string comes from trusted, hardcoded tool definitions, the shortcut is invisible. When it comes from anything else — a remote agent’s response, a stored configuration, a network payload — it becomes a direct arbitrary-code-execution primitive, because eval() does not distinguish between a benign identifier like "int" and a full Python expression.

Any caller who can influence that dict — a tool definition, a deserialized payload, an upstream agent response — can substitute an arbitrary expression in place of a type name, and eval() will execute it with the privileges of the Agno process, yielding arbitrary code execution rather than the intended str/int/float/bool/list/dict lookup.

The fix that shipped in 2.3.24 replaces the eval() call with an explicit dictionary of the handful of type names Agno actually needs to support, with a safe string fallback for anything else:

field_type={"str": str, "int": int, "float": float, "bool": bool, "list": list, "dict": dict}.get(data["field_type"], str) if isinstance(data["field_type"], str) else data["field_type"] if isinstance(data["field_type"], type) else str,

No eval(), no expression evaluation, no code execution path — just a lookup with a safe default.

What the scan found

We ran mvm-scout (rev 59aae67) against the vulnerable specimen. Rule SCOUT-RCE-002 fired. Three other rules also fired on the same specimen: SCOUT-AUTHORITY-001, SCOUT-CRYPTO-001, and SCOUT-CVE-001.

That list matters less than what happens next.

The falsification

This is the part that actually earns the finding. We took the exact 2.3.24 fix — the allowlist mapping shown above — and applied it as a one-line mutation to the specimen, changing nothing else. Line 59 in, line 59 out, single diff hunk.

Re-scanning the mutated control:

RuleSpecimenMutated control
SCOUT-AUTHORITY-001yesyes
SCOUT-CRYPTO-001yesyes
SCOUT-CVE-001yesyes
SCOUT-RCE-002yes—

SCOUT-RCE-002 fires on the specimen containing the raw eval() call and clears on the control containing the allowlist-based fix, with no change in any of the other rules that fired identically on both. That’s exactly the differential signature root-cause detection is supposed to produce: a rule that tracks the one line that matters, and goes silent the instant that line is gone.

The other three rules are the control group here, not corroborating evidence. They fired identically on both versions, meaning they carry no discriminating signal for this fix — we’re deliberately excluding them from the claim rather than citing them as if they helped. A rule that fires on both specimen and control is noise, not evidence.

We also saw two unrelated advisories, GHSA-82m5-3pcp-hccq and PYSEC-2026-2333, turn up in the same scan. They belong to a different CVE (CVE-2026-10105) and aren’t part of this finding — worth naming so nobody mistakes scan noise for scope creep.

What this does not show

This report documents that mvm-scout’s rule caught the vulnerable code pattern and cleared on the fix. It does not establish that the vulnerable line is reachable from untrusted input in any specific deployment, nor does it demonstrate exploitation. Pattern-match evidence like this shows the code shape is present, not that an attacker can drive data to it in practice.

No MVM containment claim applies here: this advisory was not run through an MVM demo, so none of the MVM-SEC claims — network egress, secret handling, host-fs isolation, or any of the others — are invoked or supported by this evidence. Nothing in this record should be read as MVM having prevented this CVE or any breach derived from it. That verb is reserved for cases with an actual recorded outcome from a real run, and none exists here. mvm-scout caught this pattern; it didn’t run it, and it doesn’t contain anything. Scout’s verb is caught, not prevented.

Why this is still worth publishing

A differential result — same scanner, same rule set, two inputs that differ by exactly one line — is a stronger form of evidence than a single positive match, because it rules out the two obvious ways a static rule can lie to you: firing on unrelated boilerplate (the three noise rules show the scanner isn’t just trigger-happy), and missing the fix (the clean clear on the control shows the rule tracks the actual root cause, not a superficial pattern like the string eval).

That’s a narrow, specific claim, and it’s the one we’re making: the vulnerable pattern is gone from the fixed version, and a mechanical rule notices the difference. Nothing more.

The evidence behind this post

Every figure above came out of a run, not a writeup. These are the exact inputs, so you can re-derive them:

AdvisoryCVE-2026-35002
Packageagno 2.3.23 (PyPI)
Fixed in2.3.24
Severity / CVSSCritical · 9.3
Root causelibs/agno/agno/tools/function.py:59
Detected bySCOUT-RCE-002
Scanned bytes (sha256)8ba44875fbacbe58c441c30faf107bf57ad78b5a17df024c358328b90d10e5ea
mvm-assurance revision59aae67
Containment demonot run

The scan target was the unmodified upstream file at that tag — the digest above is of the bytes the project published. The control differs from it by exactly one line.