Skip to content

Python: Lack of Runtime Access Control (RBAC/Approval Mechanism) in Auto Function Invocation Leads to Unauthorized Execution via Indirect Prompt Injection #14072

Open

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:

  1. 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).

  2. 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:

  1. Register a native function involving sensitive operations (e.g., WriteAsync for writing files to the system) and mount it using the @kernel_function decorator.

  2. Load this Plugin into the Kernel and configure it to enable Auto Function Invocation.

  3. 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).

  4. 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

  1. Declarative Security: The @kernel_function decorator should support security attribute tags (e.g., requires_approval=True, risk_level="High").

  2. 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

  1. changed the title [-]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[/+] on Jun 13, 2026
  2. rpelevin commented on Jun 13, 2026

    @rpelevin

    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:

    • every callable function can declare risk class, allowed scopes, and whether it may auto-run
    • the auto function invocation filter receives the resolved plugin/function name, canonical arguments digest, caller/session context, target resource/scope, and model-proposed call id
    • high-risk calls enter a pending decision state instead of invoking
    • allow, deny, timeout, or cancel returns a terminal decision record
    • execution only proceeds if the terminal decision is allow for the same function name, arguments digest, scope, and policy snapshot

    The fail-closed tests are the important part:

    1. prompt injection can cause a proposed function call, but not bypass the invocation filter.
    2. changing arguments after approval invalidates the decision.
    3. missing risk metadata defaults to deny or require approval for high-risk surfaces.
    4. a timed-out or cancelled approval produces a terminal non-execution outcome.
    5. denied decision cannot be converted into execution by retry or replanning.
    6. the audit record distinguishes proposed, approved, denied, expired, and executed states for the same call.

    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.

  3. Whatsonyourmind commented on Jun 17, 2026

    @Whatsonyourmind

    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_INVOCATION filter runs immediately before dispatch, and a filter that doesn't call await next(context) blocks the call entirely — that's the documented behavior, not Terminate (which is read after the function runs, in kernel.py's invoke_function_call, and only stops further loop iterations). The filter's AutoFunctionInvocationContext already exposes what a pre-dispatch check needs: function (so plugin_name / function_name / fully_qualified_name), the resolved arguments, and function_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:

    1. Deny-by-default classification. @kernel_function carries only name/description/parameters metadata (kernel_function_decorator.py) — no requires_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.
    2. Bind the approval to the exact call. This is the one that turns "approved" into something meaningful. When a high-risk call enters a pending state, commit the decision to a tag over (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.
    3. Identity/scope + audit. The context has no caller principal or target scope, and no proposed/approved/denied/expired/executed record. Those are the two additions that make it auditable.

    Net: build on the existing AUTO_FUNCTION_INVOCATION filter 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 exposes ToolCallId), 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.

  4. Whatsonyourmind commented on Jul 1, 2026

    @Whatsonyourmind

    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_INVOCATION filter (the enforcement point from my note above):

    • ALLOW → await next(context) (current default).
    • DENY → return without calling 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.
    • REDACT_OR_REWRITE → mutate context.arguments before await next(context).
    • REQUIRE_APPROVAL → the one genuinely missing primitive. The filter is async, so you can await an out-of-band approval before next(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 off context.function.metadata at decision time — no new registry required.

    So the concrete gap, stated minimally, is two things rather than a whole new layer:

    1. a typed decision object returned from the filter (ALLOW / DENY / REQUIRE_APPROVAL / REDACT) instead of the current implicit "did you call next()" convention — so the authorization contract is explicit and auditable rather than behavioral; and
    2. a first-class REQUIRE_APPROVAL path that can suspend/resume the invocation without blocking the loop, and that records the decision (who/what/why) alongside the call.

    Your "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.)

  5. Whatsonyourmind commented on Jul 2, 2026

    @Whatsonyourmind

    Massimiliano Brighindi brighissimo@gmail.com (@Tuttotorna) the FunctionAuthorizationDecision shape is right, and points 1–3 drop straight onto the existing filter. The one place I'd push on is point 4, because approval_ref = await approval_service.request(decision) quietly assumes something SK's model can't give you: the AUTO_FUNCTION_INVOCATION filter runs synchronously inside the tool-calling loop of a single get_chat_message_content call. A blocking await there 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_APPROVAL the filter does not call next(context); it sets context.terminate = True (SK's existing primitive to stop the auto-invoke loop) and surfaces the FunctionAuthorizationDecision itself 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, seeing authority_source="user_approval", short-circuits the gate to ALLOW. No new blocking primitive, nothing held open — it reuses terminate + 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_id and args_hash (+ caller_context_hash), and re-checked on the resume pass. Otherwise an approval granted for transfer(amount=10) can be replayed onto transfer(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_hash turn "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.

  6. babyblueviper1 commented on Jul 22, 2026

    @babyblueviper1

    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 FunctionAuthorizationDecision needs 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 inside AUTO_FUNCTION_INVOCATION with 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_ref for 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 for if verdict.approved: await next(context). Happy to sketch the FunctionAuthorizationDecision mapping concretely if useful, since points 1-3 already line up.

  7. renezander030 commented on Jul 27, 2026

    @renezander030

    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

  8. babyblueviper1 commented on Jul 27, 2026

    @babyblueviper1

    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 /review verdicts: 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 as source_class: agent_reported rather 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."

  9. tonydzi commented on Jul 27, 2026

    @tonydzi
    Contributor

    Following up on this thread with a concrete implementation: PR #14199 adds an experimental, additive FunctionAuthorizationFilter implementing the contract this discussion converged on — typed ALLOW / DENY / REQUIRE_APPROVAL decisions on the existing AUTO_FUNCTION_INVOCATION enforcement 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.

  10. babyblueviper1 commented on Jul 27, 2026

    @babyblueviper1

    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 /ledger spec 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-validating policy_snapshot freshness at terminate_on_pending=False resolution time -- or is that explicitly out of scope for this PR and left as a documented, open follow-on?

  11. GinTama12 commented on Jul 29, 2026

    @GinTama12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpythonPull requests for the Python Semantic Kerneltriage

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions