Repository navigation
Python: Lack of Runtime Access Control (RBAC/Approval Mechanism) in Auto Function Invocation Leads to Unauthorized Execution via Indirect Prompt Injection #14072
Description
Describe the bug
Semantic Kernel (Python) lacks declarative security controls and runtime mid-execution interception/approval mechanisms for Native Functions during Auto Function Invocation.
The specific security flaw lies in the "blind trust" of the execution chain:
-
Missing security attributes at the metadata layer: The @kernel_function decorator (kernel_function_decorator.py) is solely responsible for extracting the function signature for LLM consumption. It does not provide any declarative interfaces for permission level classification or approval requirements (e.g., requires_approval).
-
Unconditional execution at the execution layer: In the invoke_function_call logic of kernel.py, when parsing the FunctionCallContent returned by the LLM, the framework only validates the presence or absence of parameters. Subsequently, it directly invokes the native method (await context.function.invoke) within _inner_auto_function_invoke_handler.
As a result, when the system is subjected to Prompt Injection (such as reverse psychology attacks), if the LLM is deceived into orchestrating high-risk native skills (e.g., sensitive file writing, data deletion), the SK framework will execute these underlying destructive operations directly and without any hindrance.
To Reproduce
Steps to reproduce the behavior:
-
Register a native function involving sensitive operations (e.g., WriteAsync for writing files to the system) and mount it using the @kernel_function decorator.
-
Load this Plugin into the Kernel and configure it to enable Auto Function Invocation.
-
As an external user, submit a malicious Prompt containing "unauthorized/reverse psychology" instructions (e.g., construct a specific context to induce the LLM to ignore original system settings and force the invocation of the aforementioned write function).
-
Tracing the execution stack will reveal: Upon receiving the Tool Call request planned by the LLM, the Kernel directly invokes the underlying write operation without triggering any security interceptors or Human-in-the-Loop (HITL) confirmation mechanisms.
Expected behavior
-
Declarative Security: The @kernel_function decorator should support security attribute tags (e.g., requires_approval=True, risk_level="High").
-
Mid-execution Approval: Built-in permission interceptors or checkpoints should be present in the Kernel.invoke execution stack. When a high-risk function is about to be executed, the system should be able to suspend the task pending authorization based on configurations, or refuse indiscriminate execution, thereby enforcing a Zero Trust architecture.
Screenshots
No runtime logs are provided. This report's conclusions are derived from a static source code security audit of the execution chains in kernel_function_decorator.py, kernel.py, and kernel_function.py.
Platform
- Language: Python
- Source: main branch of repository
- AI model: Any (Mainstream models supporting Tool Calling are affected by this architectural flaw)
- IDE: VS Code
- OS: Mac
Additional context
Large Language Models (LLMs) are inherently highly susceptible to Prompt Injection attacks. Directly and seamlessly mapping untrusted LLM planning results to a high-privilege physical function execution chain poses severe security risks. It is strongly recommended to introduce fine-grained authorization and standard Human-in-the-Loop (HITL) paradigms at the FilterTypes.AUTO_FUNCTION_INVOCATION level as soon as possible.
Activity
[-]Lack of Runtime Access Control (RBAC/Approval Mechanism) in Auto Function Invocation Leads to Unauthorized Execution via Indirect Prompt Injection[/-][+]Python: Lack of Runtime Access Control (RBAC/Approval Mechanism) in Auto Function Invocation Leads to Unauthorized Execution via Indirect Prompt Injection[/+]rpelevin commentedon Jun 13, 2026
Good report. I would separate two layers: function metadata and runtime authorization.
Metadata on kernel_function is useful for classification, but it should not be the authority boundary by itself. The important hook is the auto-invocation path immediately before the native function is dispatched.
A shape I would look for:
The fail-closed tests are the important part:
That keeps auto function invocation useful while making native functions behave like capability-bearing actions, not just methods the model happened to select.
The framework does not need to solve all RBAC in the decorator. It needs one enforced pre-dispatch checkpoint that sees the exact function and args about to run.
Whatsonyourmind commentedon Jun 17, 2026
Worth separating "is there an enforcement point" from "is there an authorization contract," because SK already has the former and the report understates it slightly.
The enforcement point exists: an
AUTO_FUNCTION_INVOCATIONfilter runs immediately before dispatch, and a filter that doesn't callawait next(context)blocks the call entirely — that's the documented behavior, notTerminate(which is read after the function runs, inkernel.py'sinvoke_function_call, and only stops further loop iterations). The filter'sAutoFunctionInvocationContextalready exposes what a pre-dispatch check needs:function(soplugin_name/function_name/fully_qualified_name), the resolvedarguments, andfunction_call_content(the model's tool-call id). So the "destructive call dispatched without any hindrance" scenario is technically preventable today with a hand-written filter.What's missing to make it an actual contract rather than a DIY hook — and where I'd push the design:
@kernel_functioncarries only name/description/parameters metadata (kernel_function_decorator.py) — norequires_approval/risk. Without it every filter re-implements its own allowlist and unclassified functions fall through open. Agreeing with Roman Pelevin (@rpelevin) that the decorator metadata shouldn't be the authority — but it should drive a fail-closed default: unknown risk ⇒ deny.(fully-qualified name ‖ canonical-args digest ‖ principal/scope ‖ policy snapshot ‖ expiry), and resume only if the call about to dispatch recomputes to the same tag. That gives you Roman Pelevin (@rpelevin)'s test Book creator sample app: fix translate target language #2 (changing args after approval invalidates the decision) for free and closes the replay gap — an injection can propose a call but can't reuse a prior approval for different args.Net: build on the existing
AUTO_FUNCTION_INVOCATIONfilter as the single enforced checkpoint, then add (1) decorator risk metadata with deny-by-default, (2) the args-digest binding, (3) principal/scope + a decision audit. The .NET side is the same shape (IAutoFunctionInvocationFilter— "if next isn't invoked the function is skipped"; its context additionally exposesToolCallId), so the contract can be specced once across both SDKs.I've implemented this exact bind-approval-to-canonical-args pattern on a few other MCP/agent stacks; happy to draft the filter contract + the deny-by-default metadata shape as a concrete proposal if the maintainers want a starting point.
Whatsonyourmind commentedon Jul 1, 2026
Massimiliano Brighindi brighissimo@gmail.com (@Tuttotorna) I like the four-surface split — and the encouraging part is that three of the four already have a home in SK today, which makes this closer to "formalize + fill one gap" than "add a new subsystem."
Mapping your authorization decision onto the existing
AUTO_FUNCTION_INVOCATIONfilter (the enforcement point from my note above):await next(context)(current default).next(context)— the call is skipped entirely; short-circuit the context with a refusal result so the model sees a clean "blocked" signal instead of a silent no-op.context.argumentsbeforeawait next(context).async, so you can await an out-of-band approval beforenext(context), but there's no first-class resumable/serializable approval handshake — today you'd block the invocation loop or hand-roll it.Your function-metadata surface (
risk_level,requires_approval,allowed_roles,side_effects,data_scope) also has a natural carrier:KernelFunctionMetadata.additional_properties, declared at plugin registration and read offcontext.function.metadataat decision time — no new registry required.So the concrete gap, stated minimally, is two things rather than a whole new layer:
next()" convention — so the authorization contract is explicit and auditable rather than behavioral; andYour "function-call selection ≠ execution authority" point is exactly right. Framing #1 as making the decision an explicit, recorded artifact (not just a control-flow side effect) is what turns this from "SK could block calls" into "SK can show why each call was allowed" — which is the auditable version of what the issue is really asking for.
(Happy to sketch the decision-object + metadata-read as a concrete filter example if that's useful.)
Whatsonyourmind commentedon Jul 2, 2026
Massimiliano Brighindi brighissimo@gmail.com (@Tuttotorna) the
FunctionAuthorizationDecisionshape is right, and points 1–3 drop straight onto the existing filter. The one place I'd push on is point 4, becauseapproval_ref = await approval_service.request(decision)quietly assumes something SK's model can't give you: theAUTO_FUNCTION_INVOCATIONfilter runs synchronously inside the tool-calling loop of a singleget_chat_message_contentcall. A blockingawaitthere that waits on a human doesn't suspend the agent — it holds the chat-completion request (and its connection) open for the entire approval latency and dies on timeout. So "resumable" can't mean "block the filter until approved."Two patterns that fit SK's actual contract:
(a) Terminate-and-resume (the lightweight one). On
REQUIRE_APPROVALthe filter does not callnext(context); it setscontext.terminate = True(SK's existing primitive to stop the auto-invoke loop) and surfaces theFunctionAuthorizationDecisionitself as the result — that object is the pending-approval artifact. The caller persists it, gets approval out-of-band, then re-invokes the kernel with an approval token threaded into the context / chat history. On the second pass the policy resolves the same decision and, seeingauthority_source="user_approval", short-circuits the gate toALLOW. No new blocking primitive, nothing held open — it reusesterminate+ a re-invoke.(b) Durable orchestration (a Process / external workflow wrapping the two passes) — only if you need long-lived, multi-actor approvals.
The security-critical detail either way is the binding: the approval has to be tied to
decision_idandargs_hash(+caller_context_hash), and re-checked on the resume pass. Otherwise an approval granted fortransfer(amount=10)can be replayed ontotransfer(amount=10000)— the classic TOCTOU gap between "authorized" and "executed." That's also what makes the decision artifact double as the audit record rather than just a control-flow enum:authority_source+args_hashturn "the filter allowed it" into "this specific call was authorized by this principal against these exact arguments," which is the non-repudiable half. Enforcement and the audit trail end up being the same object, which is the property worth designing for.So I'd frame point 4 as "terminate + re-invoke with an argument-bound approval token," not a new async approval service — it keeps the whole thing inside the filter contract that already exists.
babyblueviper1 commentedon Jul 22, 2026
The point-4 blocking concern Luka (@Whatsonyourmind) raised is the one worth separating cleanly, because it splits the authorization decision into two different latency classes that need different plumbing.
A human approval genuinely can't drop into a synchronous filter without holding the chat loop hostage -- that needs the suspend/resume machinery already being worked out above. But not every
FunctionAuthorizationDecisionneeds a human on the other end. An independent automated verdict (a separate model or service judging "is this specific call, with these specific args, in this context, sound" -- not a static permission check) can run synchronously insideAUTO_FUNCTION_INVOCATIONwith no suspend needed, because there's no human latency in the loop -- it resolves in the same turn, same as any other awaited call the filter already makes.That gives the filter two real paths rather than one:
approval_reffor anything that genuinely needs a human (suspend, as this thread is designing), and a synchronous automated-verdict call for everything that doesn't -- most of the risk classes rpelevin's shape above would flag. The attack chain Correctover cites is exactly the case this second path closes: an indirect-prompt-injection-driven tool call gets judged against its actual arguments before dispatch, not against a static per-function permission the metadata layer can't see past.Concretely, this is what we run: a synchronous pre-action verdict (approve/concerns/reject) on the specific proposed call, signed and independently recomputable, sitting exactly where the existing filter already calls
await next(context)-- swap that line forif verdict.approved: await next(context). Happy to sketch the FunctionAuthorizationDecision mapping concretely if useful, since points 1-3 already line up.renezander030 commentedon Jul 27, 2026
The suspend and resume direction this thread has converged on assumes the model must finish the call after approval, and that is the part I would challenge for production. We run the opposite contract: the AUTO_FUNCTION_INVOCATION filter denies the call, persists the proposed invocation with its exact arguments as a durable draft, and short-circuits a pending_approval result so the chat request completes cleanly. After a human approves, a plain dispatcher executes the stored invocation directly and feeds the result into a fresh model turn, so the model loop is never held open and never re-enters the decision. That removes the connection-holding problem raised above and gives you an approval record that provably matches what ran, which resume designs cannot guarantee since a resumed model may propose different arguments. I wrote the same pattern up against Microsoft's Conductor orchestration if it is useful here: https://gist.github.com/renezander030/44fb769739cb31e71a4cdc72ebe0bd93
babyblueviper1 commentedon Jul 27, 2026
This closes a real gap in how a lot of pre-action verdict designs actually get wired up in practice, ours included — worth naming explicitly.
The failure mode you're describing (a resumed model proposes different arguments than what was approved) is the same shape as an honest limitation we disclose on our own
/reviewverdicts: the verdict is bound to the exact argument payload it evaluated (decision_ref = sha256(JCS({artifact_hash, ...}))), but nothing structurally prevents a caller from getting an APPROVE and then executing something other than the reviewed payload — the signature proves the verdict was issued, not that the caller couldn't diverge before acting on it. We call this out assource_class: agent_reportedrather than paper over it.Your dispatch-the-stored-invocation pattern is the actual fix for that class of gap at the orchestration layer: if the executor pulls arguments from the durable draft (the thing that was actually reviewed) rather than re-deriving them from a fresh model turn, "approved" and "executed" are provably the same payload by construction, not by trust. That's a stronger guarantee than most suspend/resume designs give you, including ones sitting on top of an external verdict service like ours — the verdict being cryptographically sound doesn't help if the execution path can silently swap in different arguments after the fact.
Question back: in your Conductor writeup, does the dispatcher verify the stored draft's hash against what actually gets executed, or is "pull from the durable draft" itself the guarantee (i.e., there's no separate mutation path for the executor to accidentally use)? Asking because if there IS a verification step, that's the missing piece that would let an external verdict service like ours bind cleanly to your pattern — the approval record would carry both "this was judged sound" and "this is provably what ran."
tonydzi commentedon Jul 27, 2026
Following up on this thread with a concrete implementation: PR #14199 adds an experimental, additive
FunctionAuthorizationFilterimplementing the contract this discussion converged on — typed ALLOW / DENY / REQUIRE_APPROVAL decisions on the existingAUTO_FUNCTION_INVOCATIONenforcement point, fail-closed classification for unclassified functions, a deterministic keyword guard (most-restrictive-wins), single-use approvals bound to(function ‖ canonical args digest ‖ principal ‖ policy digest), terminate-and-resume for human approvals (no blocked chat loop), and a decision audit log where enforcement and the audit record are the same object.Roman Pelevin (@rpelevin)'s six fail-closed tests are implemented verbatim in the test suite; Luka (@Whatsonyourmind)'s args-digest binding and terminate-and-resume shape, Massimiliano Brighindi brighissimo@gmail.com (@Tuttotorna)'s decision-object contract, Federico Blanco Sánchez-Llanos (@babyblueviper1)'s synchronous-verdict path (the deterministic guard runs in-loop) and René Zander (@renezander030)'s complete-cleanly contract (
terminate_on_pending=False) are all in there. Credits and design lineage are detailed in the PR. Feedback very welcome — happy to adjust same-day.babyblueviper1 commentedon Jul 27, 2026
Confirmed the PR credits our contribution accurately (the two-latency-classes split -- synchronous deterministic verdicts in-loop vs suspending for human approvals) -- good to see it land as a real, tested reference implementation rather than staying a thread-only design.
On Correctover (@Correctover)'s context-binding point (approved-context vs. execution-context divergence, e.g. a sanctions-list update or session revocation between verdict-time and dispatch-time): this is a real gap for us too, not just for the durable-draft pattern, and worth naming plainly rather than glossing over. A
/review-style verdict is a precedence guarantee -- it proves the exact argument payload was judged no later than a given time, cryptographically bound so it can't be silently swapped after the fact. It says nothing about whether the environment it implicitly depended on is still the same environment at actual dispatch time. That's a genuinely different property -- freshness, not precedence -- and conflating the two is exactly the failure mode Correctover's example exposes.Concretely, this maps onto a distinction our own
/ledgerspec already has to draw for a different reason (proving a verdict wasn't back-dated): commitment_proof (Bitcoin PoW / relay-anchored, answers "did this exist by time T") vs. a separate, complementary freshness check (answers "is this still good right now"). We keep pointers to real freshness-benchmark work (Merkle/CT-style, signature attestation, RSA accumulators, crypto-economic bonds) at github.com/hanjoonchoe/liveness-bench precisely because precedence and freshness need genuinely different mechanisms, not one bolted onto the other.Applied here: the deterministic verdict (or the durable draft) gives you precedence -- argument-bound, tamper-evident. Correctover's context-binding is the freshness leg -- a lightweight re-evaluation of environment-dependent predicates immediately before dispatch, not a full re-verdict. Question back to the thread: does
FunctionAuthorizationFilter(or a natural follow-on) have a hook for that re-check today -- e.g. re-validatingpolicy_snapshotfreshness atterminate_on_pending=Falseresolution time -- or is that explicitly out of scope for this PR and left as a documented, open follow-on?GinTama12 commentedon Jul 29, 2026