TL;DR
- Two critical Semantic Kernel advisories from February 2026 (both CVSS 9.9) show the same failure from two angles. In CVE-2026-26030 a model-controlled search parameter reached
eval(). In CVE-2026-25592 a helper method was exposed as a model-callable tool and accepted any file path. Microsoft's May 7, 2026 write-up draws the conclusion directly: treat the LLM as a non-security boundary. - The practical rule: every argument a model writes is untrusted input, exactly like a form field from the internet. The gate belongs at the tool boundary, not in the prompt.
- I built both gates in a few dozen lines of Python and ran them on a small corpus of my own: a string blocklist let 11 of 14 adversarial filter expressions through; an AST allowlist let 0 of 14 through while still accepting all 4 benign filters. A naive
startswith(root)path check let 2 of 7 bad paths through; canonical containment let 0 of 7 through. - The corpus is small and written by me, so read the numbers as a demonstration of failure modes, not as a benchmark. The code is included so you can extend the corpus with your own payloads.
- A gate removes one class of damage. It does not make a fooled agent honest. Pair it with deny-by-default tool exposure and an audit of what is actually registered.
What did the two Semantic Kernel CVEs actually show?
Microsoft's Defender Security Research team (Uri Oren, Amit Eliahu, Dor Edry) published "When prompts become shells" on May 7, 2026. It covers two vulnerabilities in Semantic Kernel, an agent framework with more than 27,000 GitHub stars. I cross-checked the version and severity data against the GitHub advisories rather than relying on the blog alone.
| CVE-2026-26030 | CVE-2026-25592 | |
|---|---|---|
| Component | In-memory vector store, filter feature (Python) |
SessionsPythonPlugin (.NET) |
| Weakness | CWE-94, code injection | CWE-22, path traversal |
| Severity | Critical, CVSS 9.9 | Critical, CVSS 9.9 |
| Fixed in |
semantic-kernel 1.39.4 |
Microsoft.SemanticKernel.Plugins.Core 1.71.0 |
| Advisory published | 2026-02-19 | 2026-02-06 |
CVE-2026-26030. The vector store builds its default filter as a Python lambda from a string and runs it through eval(). According to Microsoft, the problem is that the parameter value is model-controlled and not sanitized before interpolation. A prompt injection can steer the model into emitting a search parameter that closes the string literal and appends code. A blocklist in the original code was bypassed by walking Python's type system, which is the classic route from "an object" to "any object in the process." The fix described in the write-up is layered: an allowlist of AST node types, plus a blocklist of dangerous attribute names, among other changes.
CVE-2026-25592. A method named DownloadFileAsync was marked with the [KernelFunction] attribute, which is what makes a method visible to the model as a callable tool. It was meant to be an internal helper. Because it was exposed, the model could supply the local destination path. Microsoft describes an attack chain in which an injected instruction makes the agent write a script into a sandbox, then write a file to the Windows Startup folder, so code runs on the next login. The advisory's workaround is instructive: a function invocation filter that checks the arguments of the two file-transfer functions and ensures localFilePath is on an allowlist.
Neither bug is exotic. Both are among the oldest bugs in the book (string-built code, unchecked path), reached through a new kind of caller. What changed is that the "user" supplying the argument is now a model that reads untrusted web pages, emails and documents.
Why is "the model is not a security boundary" an engineering rule?
A model's output is a function of everything in its context. If any of that context came from a source an attacker can influence, the output is attacker-influenced. Palo Alto's Unit 42 reported in March 2026 that indirect prompt injection is being used in the wild, with attacker intents that include data destruction (14.2% of the cases they catalogued) and social engineering as the dominant jailbreak style (85.2%), as reported by the authors. Whatever the exact shares in your traffic, the direction is clear: the text that reaches your model is not all written by people you trust.
So the model cannot be the thing that decides whether localFilePath is safe. It can be asked nicely, and it can be fine-tuned to refuse, but those are probabilistic filters on the input side. The tool boundary is where you have something deterministic: a function receives a string and either that string is acceptable or it is not.
This is the same reasoning web developers applied to SQL injection two decades ago. You do not ask the browser to promise it will not send quotes. You parameterize the query. For agents the equivalent is: declare what each argument may look like, in code, and reject everything else before the tool body runs.
Why does a string blocklist fail on model-written expressions?
Suppose your tool accepts a filter expression such as x.price < 20 and x.category == 'books'. The quickest "fix" for a vulnerable eval is to reject strings containing scary words. Here is that gate:
def naive_filter(expr: str) -> bool:
banned = ("import", "exec", "eval", "os.", "system", "open(")
return not any(b in expr for b in banned)
It stops __import__('os').system('id'), eval('1') and open('/etc/hostname'). It does not stop anything that reaches dangerous objects without naming them. In a fresh Python 3.12.10 interpreter, this harmless snippet shows how much is reachable from an empty tuple:
f = eval("lambda x: len(().__class__.__base__.__subclasses__())")
print(f(None)) # 164 classes reachable in a bare interpreter
An application that has imported a web framework and a few libraries has more classes in that list, and in a real process some of them can open files or start processes. The attacker never needs the word import. The same trick works through x.__class__.__mro__, through string concatenation fed to getattr, and through comprehensions.
The alternative is to stop asking "does this look dangerous?" and ask "is this one of the shapes I intended to allow?" For expressions that means parsing, then allowing only specific AST node types, specific field names, and specific constant types:
import ast
ALLOWED_FIELDS = {"price", "category", "rating"}
ALLOWED_NODES = (ast.Expression, ast.BoolOp, ast.And, ast.Or, ast.Not, ast.UnaryOp,
ast.Compare, ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
ast.Constant, ast.Name, ast.Load, ast.Attribute)
def gate_filter(expr: str) -> bool:
try:
tree = ast.parse(expr, mode="eval")
except SyntaxError:
return False
for node in ast.walk(tree):
if not isinstance(node, ALLOWED_NODES):
return False # no Call, Lambda, Subscript, comprehension, f-string
if isinstance(node, ast.Name) and node.id != "x":
return False # only the row variable
if isinstance(node, ast.Attribute):
if not (isinstance(node.value, ast.Name) and node.value.id == "x"
and node.attr in ALLOWED_FIELDS):
return False # only x.<known field>, never x.__class__
if isinstance(node, ast.Constant) and not isinstance(node.value, (int, float, str, bool)):
return False
return True
I ran both gates on 4 benign filters and 14 adversarial ones: class-hierarchy walks, a lambda call, getattr with split strings, type(), a walrus assignment, an f-string that reads __dict__, an unknown field, and the three that name banned words.
| Gate | Benign allowed (of 4) | Adversarial allowed (of 14) |
|---|---|---|
| String blocklist | 4 | 11 |
| AST allowlist | 4 | 0 |
The blocklist's 11 misses are not a surprise. I wrote most payloads to avoid its words, which is exactly what an attacker, or a model steered by one, will do. The point of the table is the asymmetry. A blocklist's failures are unbounded because the space of "dangerous strings" is unbounded. An allowlist's failures are bounded by what you chose to allow, and the cost of a miss in the other direction (a legitimate filter rejected) shows up as a visible, fixable error instead of a silent hole.
One more property is worth stating. Even after a correct gate, the evaluated expression should run with no builtins and only the row object in scope. The gate is a first layer; sandboxed evaluation is the second. Microsoft's fix is likewise layered rather than a single check.
How do you gate a file path argument correctly?
Path arguments fail in three common ways: a check that only looks for .., a prefix check that is not a path-boundary check, and a check done before normalization. I implemented the first two next to a canonical containment check:
import posixpath
ROOT = "/sandbox/session-42"
def _full(p: str) -> str:
return posixpath.normpath(p if p.startswith("/") else posixpath.join(ROOT, p))
def naive_dotdot(p): return ".." not in p
def naive_startswith(p): return _full(p).startswith(ROOT)
def canonical_gate(p): return posixpath.commonpath([ROOT, _full(p)]) == ROOT
The corpus was 4 legitimate paths and 7 bad ones: ../../etc/cron.d/job, /etc/passwd, out/../../session-43/x, a bare .., an escape through /sandbox/session-42/../../root/.ssh/authorized_keys, and two sibling-directory tricks.
| Gate | Benign allowed (of 4) | Adversarial allowed (of 7) |
|---|---|---|
".." substring check |
4 | 2 |
startswith(root) after normalizing |
4 | 2 |
Canonical containment (commonpath) |
4 | 0 |
The two that slip past the .. check are absolute paths, which have no .. to find (/etc/passwd, /sandbox/session-42-evil/x). The two that slip past startswith are sibling directories whose names begin with the root's name: /sandbox/session-42-evil/x, and the same directory reached via ... This is the classic prefix bug. /sandbox/session-42-evil starts with /sandbox/session-42 as a string, but it is not inside that directory as a path. Comparing path components through commonpath (or Path.relative_to in pathlib) closes it.
According to Microsoft's description, the Semantic Kernel fix used the .NET counterpart of this idea: canonicalizing with Path.GetFullPath() before comparing.
What does an argument gate not catch?
I want to be explicit about the limits, because "we added a gate" is often treated as "we are safe."
-
Lexical is not physical.
posixpath.normpathworks on strings. If the sandbox contains a symlink pointing outside it, a lexically clean path can still escape. On a real filesystem, resolve withos.path.realpath(orPath.resolve()) and compare the resolved path, and ideally run the tool inside a container where the escape does not exist. -
Check and use can drift. If the gate validates one string and a later layer decodes, expands or re-joins it, you validated the wrong thing. One of my test inputs,
out/..%2f..%2fetc/x, is contained as a literal directory name and my gate allows it. That is correct for my code. If a downstream component URL-decodes it, you have a different bug that the gate cannot see. Validate the exact value the tool body will use. -
A gate constrains shape, not intent. My allowlist accepts the string
"' or 1 or '"because it parses as a single string constant, which is not code. As a filter it is truthy for every row, so it returns everything. That is a logic and data-exposure problem, not code execution, and the AST gate correctly does not claim to solve it. If a filter result is sensitive, authorization has to be enforced on the rows, not on the expression. - Allowed paths can still be harmful. Writing a malicious script inside the sandbox directory is a legal write. That is why the second half of Microsoft's attack chain, a write to the Startup folder, matters: the sandbox boundary is what turns a legal write into a harmless one. Containment and execution policy have to agree.
- The gate cannot protect a tool that should not exist. CVE-2026-25592 was not a missing check in a function that was supposed to be callable. It was a function that was never meant to be callable at all.
Where should the gate live in the stack?
Three placements, from weakest to strongest:
- In the prompt. "Never write outside /sandbox" is a hope.
- In each tool body. Better, but every tool author must remember, and reviewers must re-check each one.
- In one invocation layer that every tool call passes through. This is what the advisory's workaround uses: a function invocation filter that inspects arguments before dispatch. One place, one policy table, one test suite.
A policy table can be tiny and still useful. Here is an illustrative structure:
POLICY = {
"search_products": {"filter": gate_filter},
"write_report": {"path": canonical_gate},
# anything not listed here is denied
}
def invoke(tool_name: str, **kwargs):
rules = POLICY.get(tool_name)
if rules is None:
raise PermissionError(f"tool not approved: {tool_name}")
for arg, check in rules.items():
if arg not in kwargs or not check(kwargs[arg]):
raise PermissionError(f"argument rejected: {tool_name}.{arg}")
return TOOLS[tool_name](**kwargs)
Deny-by-default addresses the CVE-2026-25592 failure mode in a way an argument check cannot. A helper that was accidentally registered is not in POLICY, so it cannot be called. You can also turn the exposure audit into a CI test:
def test_registered_tools_match_policy():
assert set(TOOLS) == set(POLICY), "a tool is registered without a policy, or a policy has no tool"
A test like this would flag a newly exposed helper on the pull request that added the attribute, because the registered set changed without a matching policy entry.
A checklist you can adopt this week
- List every function the model can call by reading the framework's actual registration, not the design doc.
- For each argument, write the allowed shape (type, range, allowed fields, root directory) before writing the tool.
- Parse, do not pattern-match. Use an AST, a URL parser, a path-component comparison. Avoid substring blocklists as the primary control.
- Canonicalize, then check, then use the canonical value. Never check one string and use another.
- Run evaluation with no ambient authority: no builtins, no network, a scoped filesystem, a non-privileged account.
- Make the denial visible. A rejected argument should be logged with the tool name and argument name so you can distinguish a buggy prompt from an attack.
- Grow the corpus. Every real bypass you find goes into the test list. Mine has 21 payloads; yours should end up with hundreds.
FAQ
Is prompt injection the root cause of these CVEs?
It is the trigger, not the vulnerability. The vulnerabilities are an unsanitized value reaching eval() and an unchecked path in an exposed method. Remove those and a successful prompt injection has much less to work with. Fix both layers if you can.
Why not just fine-tune the model to refuse dangerous arguments?
Refusal training lowers the rate; it does not give a guarantee, and attackers adapt. A deterministic check at the tool boundary gives a guarantee for the class of input it covers. Use both, and count only the deterministic one when you reason about worst cases.
Is an AST allowlist enough to make eval safe?
It is a strong first layer for a narrow expression language, but I would still evaluate with empty builtins and a restricted scope, and I would prefer not to use eval at all. If you can express filters as structured data (field, operator, value) and build the predicate yourself, do that instead.
Does commonpath handle symlinks?
No. It is lexical. On a real filesystem, resolve symlinks first and compare resolved paths, or contain the process so that an escape has nowhere to go.
How big a test corpus do I need?
Start with every bypass class you can name, then add each real incident. My 21 payloads are enough to show the failure modes in this post, not enough to certify a production gate.
Do the demo payloads execute anything?
No. Apart from the one class-count snippet shown above, the "dangerous" payloads are strings that are only parsed, never evaluated, and the script performs no file writes and runs no shell commands.
Further reading
- Your Agent's Gate Threshold Is a Winner's Curse: Tuning Per-Action Thresholds Without Fooling Yourself
- Indirect Prompt Injection Through Tool Descriptions and Tool Output: How Untrusted Metadata Hijacks Agents
Sources
- Microsoft Defender Security Research Team, "When prompts become shells: RCE vulnerabilities in AI agent frameworks", May 7, 2026.
- GitHub Advisories GHSA-xjw9-4gw8-4rqx (CVE-2026-26030) and GHSA-2ww3-72rp-wpp4 (CVE-2026-25592).
- Palo Alto Networks Unit 42, "Fooling AI Agents: Web-Based Indirect Prompt Injection Observed in the Wild", March 3, 2026.
- Test corpus and results: my own, illustrative, 14 filter and 7 path payloads.
Top comments (0)