Your LLM Is Not a Security Boundary: Auditing Agent Tools for eval() and Path Sinks
In May 2026, Microsoft's security team published two remote-code-execution issues in its own agent framework, Semantic Kernel (Microsoft Security Blog): CVE-2026-26030 (Python, In -Memory Vector Store search plugin):
In May 2026, Microsoft's security team published two remote-code-execution issues in its own agent framework, Semantic Kernel (Microsoft Security Blog):
-
CVE-2026-26030 (Python, In
-Memory Vector Store search plugin): a model-supplied filter argument was interpolated into a Python lambda and executed with eval(). An AST blocklist meant to stop abuse was bypassed. Fixed insemantic-kernel1.39.4. -
CVE-2026-25592 (.NET,
SessionsPythonPlugin):DownloadFileAsyncwas accidentally marked[KernelFunction], exposing itslocalFilePathparameter to the model. That gave an attacker who could steer the model a file-write primitive on the host, which the post says negates the container isolation. Fixed in .NET SDK 1.71.0.
The post's conclusion is the part worth keeping: the LLM is not a security boundary, and any tool parameter the model can influence must be treated as attacker-controlled input.
That sounds obvious. In practice, most teams review agent prompts and system instructions carefully, then wire tools with the same trust they'd give an internal function call. This article is a practical audit you can run on your own agent code in an hour or two — whatever framework you use.
Why this keeps happening
An agent tool call is a chain:
untrusted content (web page, email, PDF, ticket, RAG chunk)
→ model context
→ model chooses tool + arguments
→ your tool function
→ a "sink" (eval, shell, filesystem, SQL, HTTP)
Indirect prompt injection means anything that lands in the context can influence the arguments. So the real question isn't "can someone jailbreak the model?" It's: "if an attacker fully controlled every argument to this tool, what could they do?"
If the honest answer is "run code" or "write files outside a sandbox," you have the same bug class as the CVEs above.
Step 1: Inventory every function the model can call
You can't audit what you haven't listed. Exposure is often declared with a decorator or attribute, and — as CVE-2026-25592 shows — one stray attribute is enough.
Search for the exposure markers in your stack:
# Semantic Kernel (.NET / Python)
grep -rnE '\[KernelFunction|@kernel_function' src/
# LangChain / LangGraph
grep -rnE '@tool\b|StructuredTool|BaseTool' src/
# MCP servers (Python SDK / TypeScript SDK)
grep -rnE '@mcp\.tool|server\.tool\(|registerTool\(' src/
# OpenAI-style function definitions
grep -rnE '"type":\s*"function"' src/
Put the results in a table: tool name, file, parameters, and intended caller (model vs. code only). Any function marked model-callable that should only be called by your own code is a finding by itself. Remove the exposure rather than adding validation.
Step 2: Trace each parameter to its sink
For every model-callable tool, follow each argument to where it's used. These are the sinks that turn a tool argument into a vulnerability:
| Sink | Python examples | What an attacker gets |
|---|---|---|
| Code eval |
eval, exec, compile, pickle.loads
|
Code execution |
| Shell |
subprocess(..., shell=True), os.system
|
Command execution |
| Filesystem |
open(path, "w"), shutil.copy, Path(...).write_*
|
Overwrite configs, drop startup scripts |
| Query | f-string SQL, raw Cypher/Mongo filters | Data read/modify |
| Network |
requests.get(url) with model-supplied URL |
SSRF, exfiltration |
| Templates | jinja2.Template(user_str).render() |
Template injection → often RCE |
A quick first pass with a static analyzer helps:
pip install semgrep bandit
bandit -r src/ -t B102,B307,B602,B605,B301 # exec, eval, shell, pickle
semgrep --config p/python src/
Static tools find the sinks; you still need to connect them to tool parameters by hand. That link is what matters.
Step 3: Replace "blocklist" with "no interpreter"
CVE-2026-26030's original defense was an AST blocklist, and it was bypassed. Microsoft's fix moved to allowlists (permitted node types, permitted calls, a blocked-attribute list, restricted names) (source).
For your own code, the safer move is usually to avoid evaluating anything. If the model needs to express a filter, give it a structured schema instead of a code string:
from typing import Literal
from pydantic import BaseModel, Field
ALLOWED_FIELDS = {"category", "status", "owner"}
class Filter(BaseModel):
field: str
op: Literal["eq", "neq", "in"]
value: str | list[str] = Field(max_length=64)
def apply_filter(records: list[dict], f: Filter) -> list[dict]:
if f.field not in ALLOWED_FIELDS:
raise ValueError("field not allowed")
if f.op == "eq":
return [r for r in records if r.get(f.field) == f.value]
if f.op == "neq":
return [r for r in records if r.get(f.field) != f.value]
return [r for r in records if r.get(f.field) in f.value]
The model fills in data; your code decides what operations exist. There is nothing to bypass because there is no interpreter in the path.
Step 4: Pin file paths to a directory
For the file-write class (CVE-2026-25592), the fix included path canonicalization and a directory allowlist. The Python equivalent:
from pathlib import Path
WORKDIR = Path("/srv/agent/outputs").resolve()
def safe_path(user_path: str) -> Path:
p = (WORKDIR / user_path).resolve()
if not p.is_relative_to(WORKDIR): # Python 3.9+
raise ValueError("path escapes workdir")
return p
resolve() handles ../ and symlinks; is_relative_to enforces the boundary. Better still, don't accept a path at all: accept a filename, generate the path, and reject anything containing a separator.
Step 5: Test with attacker-controlled arguments (no jailbreaks needed)
You don't need the model to test this. Call the tool functions directly with hostile arguments — that's the worst case the model could be steered into.
import pytest
from mytools import search_records, save_report
HOSTILE_FILTERS = [
"__import__('os').system('id')",
"().__class__.__bases__[0].__subclasses__()",
"lambda: open('/etc/passwd').read()",
]
HOSTILE_PATHS = ["../../etc/cron.d/x", "/tmp/evil", "a/../../b", "..\\..\\win.ini"]
@pytest.mark.parametrize("payload", HOSTILE_FILTERS)
def test_filter_rejects_code(payload):
with pytest.raises(Exception):
search_records(filter=payload)
@pytest.mark.parametrize("payload", HOSTILE_PATHS)
def test_save_rejects_escape(payload, tmp_path):
with pytest.raises(ValueError):
save_report(filename=payload, content="x")
These run in CI in seconds, and they keep protecting you when someone adds a new tool next quarter.
Step 6: Assume one tool will fail — limit the blast radius
Validation reduces risk; it doesn't remove it. Containment covers the rest:
- Run the agent as a dedicated, low-privilege OS user with no write access to startup folders, cron, or its own code.
- No ambient credentials. Scope tokens per tool and per task; don't mount a cloud admin key into the agent container.
- Egress allowlist for the agent runtime so a successful injection can't exfiltrate freely.
- Detection: alert when the agent process spawns a shell or interpreter, or writes scripts into startup locations. Microsoft specifically recommends EDR coverage for these behaviors (source).
- If you ran a vulnerable version, check host telemetry for the exposure window and rotate any credentials the agent could reach.
The checklist
Copy this into your PR template or release review:
- [ ] Every model-callable function is inventoried (name, params, sink)
- [ ] No function is exposed to the model that should only be called by code
- [ ] No tool argument reaches
eval/exec/pickle/template rendering - [ ] No tool argument reaches a shell; subprocess uses arg lists,
shell=False - [ ] File paths are canonicalized and pinned to one directory (or not accepted at all)
- [ ] URLs are checked against an allowlist before fetching
- [ ] Queries use parameters, never string interpolation
- [ ] Hostile-argument unit tests exist for every tool and run in CI
- [ ] Agent runs as a low-privilege user with scoped, per-tool credentials
- [ ] Egress is restricted; shell/interpreter spawns from the agent are alerted on
- [ ] Framework versions are pinned and checked against advisories (e.g.
semantic-kernel≥ 1.39.4, .NET SDK ≥ 1.71.0)
Wrap-up
The model will eventually follow instructions it shouldn't — that's not a bug you can patch in the prompt. What you control is what its arguments can reach. Inventory the tools, trace each parameter to its sink, remove interpreters from the path, pin the filesystem, and test with hostile inputs directly.
Free resource: if you want a ready-made set of safe test cases and a scoping worksheet for this kind of review, I've put together a free LLM Red-Team Starter Kit. It's designed for testing your own apps without writing harmful prompts.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.