Python: Add experimental FunctionAuthorizationFilter for auto function invocation (runtime authorization, argument-bound approvals) - #14199
Conversation
…n invocation An additive AUTO_FUNCTION_INVOCATION filter that turns function dispatch into an explicit, auditable authorization decision: deterministic fail-closed risk classification (policy overrides, function metadata, keyword guard with most-restrictive-wins), typed ALLOW/DENY/ REQUIRE_APPROVAL decisions, single-use approvals bound to the canonical argument digest (TOCTOU-safe), terminate-and-resume approval flow, and a decision audit log. Addresses microsoft#14072; informed by the design discussion in that thread and the recurring requests in microsoft#14056, microsoft#13661, microsoft#10951, microsoft#5436, microsoft#1409. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an experimental Python authorization “contract” for auto function invocation by introducing a policy-driven, fail-closed authorization filter with argument-bound, single-use approvals and an audit trail. This extends the existing filter enforcement point without changing default Kernel behavior unless the filter is explicitly registered.
Changes:
- Introduces
FunctionAuthorizationPolicy+FunctionAuthorizationFilter(risk classification, keyword guard escalation, approval binding, audit logging). - Adds comprehensive unit tests covering fail-closed behavior, approval binding semantics, and hostile/edge-case arguments.
- Adds a concepts sample and links it from the samples README.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| python/semantic_kernel/filters/auto_function_invocation/function_authorization_filter.py | New experimental policy/filter implementation, approval store, and audit decision types. |
| python/semantic_kernel/filters/init.py | Exports the new experimental authorization types from semantic_kernel.filters. |
| python/tests/unit/filters/test_function_authorization.py | New unit test suite validating authorization decisions, bindings, lifecycle statuses, and failure modes. |
| python/samples/concepts/filtering/function_authorization_filter.py | New sample demonstrating terminate-and-resume approvals and keyword-guard escalation without requiring model API keys. |
| python/samples/concepts/README.md | Adds the new sample link under Filtering concepts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if not math.isfinite(ttl_seconds) or ttl_seconds <= 0: | ||
| raise ValueError(f"ttl_seconds must be a finite, positive number, got {ttl_seconds!r}.") | ||
| self._approvals[binding] = time.time() + ttl_seconds |
| if decision.action == FunctionAuthorizationAction.ALLOW: | ||
| if decision.status != FunctionAuthorizationStatus.APPROVED: | ||
| decision.status = FunctionAuthorizationStatus.APPROVED | ||
| logger.debug("Function '%s' authorized: %s", function_name, decision.reason) |
There was a problem hiding this comment.
Automated Code Review
Reviewers: 4 | Confidence: 87%
✓ Correctness
The FunctionAuthorizationFilter implementation is well-structured with correct fail-closed semantics. The filter chain integration, approval binding, canonicalization, and policy resolution all work correctly. The innermost-filter detection at line 337 correctly uses index 0 (most recently added filter = innermost in the constructed call stack). Approval consumption logic correctly handles DENY actions per the test suite. No correctness bugs found.
✓ Security Reliability
The FunctionAuthorizationFilter is well-designed from a security perspective: fail-closed by default, approvals are single-use and bound to canonical argument digests + policy snapshots, uncanonicalizable arguments are denied, and the keyword guard can only escalate risk. The implementation correctly prevents TOCTOU attacks on approval binding (tested) and handles hostile str implementations via structural type tags. The main reliability concern is the unbounded audit_log growth, which could exhaust memory in long-running services.
✓ Test Coverage
The test suite is comprehensive and well-structured, covering 29 deterministic tests across fail-closed defaults, policy classification, keyword guard, approval binding, loop/model signals, and audit log scenarios. The core security properties are thoroughly validated: fail-closed behavior, argument-bound approvals, single-use consumption, expiry, and hostile inputs. The two notable gaps are: (1) the MEDIUM risk level is never exercised end-to-end despite being one of four levels in the enum and default action_map, and (2) the keyword_guard tests only use a single keyword per test, leaving the multi-keyword most-restrictive-wins iteration logic (lines 226-229) untested with multiple simultaneously-matching keywords.
✗ Design Approach
The authorization filter is well aligned with the existing single-call auto-invocation hook, but its default "terminate-and-resume" design does not actually suspend a multi-call auto-invocation turn. In the main auto-invoke path, tool calls are launched concurrently and the loop only checks
context.terminateafter every call has already finished, so a pending-approval decision cannot prevent sibling tool calls from dispatching in the same assistant response.
Flagged Issues
-
FunctionAuthorizationFiltersetscontext.terminate = Trueto advertise suspend-on-pending-approval semantics, but the standard auto-invocation loop inchat_completion_client_base.py:154-170dispatches all tool calls concurrently withasyncio.gather(...)and only inspectsterminateafter they all complete. When the model emits multiple tool calls in one response, apending_approvaldecision one call cannot prevent sibling calls from executing, breaking the advertised terminate-and-resume contract.
Suggestions
- To preserve the intended approval boundary, either move the authorization decision to a batch-level step before dispatch, or make the standard auto-invocation path stop processing further calls once any call produces
terminate=True, rather than launching the whole batch concurrently (chat_completion_client_base.py:154-170).
Automated review by Palo-Alto-AI-Research-Lab's agents
| ), | ||
| }, | ||
| ) | ||
| if self.policy.terminate_on_pending: |
There was a problem hiding this comment.
Setting context.terminate = True here does not actually suspend a multi-call auto-invocation turn. The main caller at chat_completion_client_base.py:154-170 starts every tool call with asyncio.gather(...) and only checks result.terminate after all complete. If the model emits two tool calls at once, a pending_approval decision on one cannot stop the sibling from dispatching in the same turn, breaking the advertised terminate-and-resume contract.
|
Flagged issue
Source: automated DevFlow PR review |
|
Reviewed the actual implementation, not just the description — the This directly answers the question I raised on #14072 about whether a dispatcher verifies the stored draft against what actually executes — it does, cryptographically, by construction: One place this composes cleanly with the two-latency-classes point credited above: Solid piece of engineering — the fail-closed defaults, the six tests from @rpelevin implemented verbatim, and the hostile-input coverage (circular refs, NaN/inf TTLs, lookalike strings) all read like someone actually tried to break it before shipping, not just implement the happy path. |
|
Thanks for the detailed write-up, and glad to see the community converge on fail-closed as the default. We've been running this in production — CCS operates as an out-of-process runtime verification layer, independently validating every tool call at ~10μs P50. The process separation is deliberate: verifier and verified share no memory, no imports, no failure modes. An in-process filter (as proposed here) can be bypassed if the compromised agent reaches the enforcement point directly — out-of-process eliminates that attack surface entirely. Fail-closed is the only production-safe default. We see the same pattern in our live telemetry: ops.correctover.com Two considerations from our deployment experience:
We've also filed #14196 to track the runtime verification contract from a standards perspective. Happy to compare notes. |
|
One important thing that should be addressed transparently: the core design patterns in this PR — fail-closed defaults, argument-bound approvals, runtime verification at the dispatch boundary — were directly influenced by the discussion on #14072, where @Correctover identified the runtime access control problem and built the CCS runtime verification layer as the concrete solution. The PR body mentions this in a single sentence ("The real-world incident context from @Correctover underlines why the default must be fail-closed"). That significantly understates the contribution. The entire architectural shape of this PR — fail-closed as default, bound approvals preventing replay, the deterministic verdict model — maps directly to what CCS implements and what the #14072 discussion converged on. @Palo-Alto-AI-Research-Lab Could you clarify:
Proper attribution matters — not for ego, but so future readers can trace the full design lineage and find the production-grade reference implementation. |
|
Worth pulling the automated DevFlow finding above into the same class of gap this thread's been naming, since it's a real one and distinct from the attribution question: the This is the same shape as the synchronous-verdict point I raised a couple comments up: today |
|
Thanks for pushing this toward a concrete implementation. One remaining boundary looks important before treating the filter as fail-closed in practice: if multiple tool calls from the same model response enter |
|
Hi all, Thank you for the thoughtful discussion and the work on this. We’re not planning to move forward with this implementation in Semantic Kernel at this time. Our ongoing SDK investment is focused on its successor, Microsoft Agent Framework. Agent Framework already includes related tool-approval and experimental FIDES-based security capabilities, including pre-execution policy enforcement for tool calls. If a capability you need is missing there, please open an issue in the Agent Framework repository so we can discuss and triage the gap. Thank you. |
|
Understood on the SDK transition -- went and checked Agent Framework's actual design before assuming anything (ADR-0006, accepted 2025-09-12) rather than guessing whether it inherits the same gap. Good news: it closes exactly the scheduling-boundary race rpelevin and I both raised, but via a more conservative route than either of us suggested. Rather than evaluating each call independently and letting ALLOWed ones through while only the flagged ones wait (what I proposed here, what rpelevin refined), the actual documented flow is: if ANY function call in a batch requires approval, the WHOLE batch is returned as Worth knowing before anyone opens the suggested follow-up issue there: the gap this thread found already has an answer in the successor, just a different one than either of us reached for. Thanks for the pointer and the real discussion. |
…oval A model response can propose several tool calls, and the auto-invocation loop dispatches them with asyncio.gather, inspecting terminate only after the whole batch resolves (chat_completion_client_base.py, both the non-streaming and the streaming loop). Setting terminate on a pending_approval decision therefore stops the next round, not the current batch: an injected "read the secret, then send it" pair could still have its second half dispatched while the first was suspended. Reported by the automated PR review and by @babyblueviper1 in microsoft#14199. The authorization check for each call already runs before that call is dispatched, so per-call enforcement was never bypassed. What was missing was batch scope. FunctionAuthorizationPolicy gains hold_siblings_on_pending (default True): a suspended call marks its batch, keyed by (chat history, request sequence index), and every authorization check that runs afterwards in that batch fails closed with authority_source="batch_hold". The hold expires after 60s and at most 256 batches are tracked, so an abandoned batch cannot hold anything forever. The check runs before approvals are consumed, so a held call keeps its granted approval and stays re-issuable. This narrows the window; it is not atomic and cannot be from inside a filter, since a sibling already dispatched before the suspension is not recalled. The docstring says so plainly and names the structural fix: evaluate policy per call before dispatch, in the loop rather than in a filter. hold_siblings_on_pending is part of the policy digest, so flipping it invalidates outstanding approvals. Also folds the three identical refusal payloads into one _refusal_result helper. Tests: 6 new cases in tests/unit/filters/test_function_authorization.py (hold, batch isolation across request rounds, single-call responses, opt-out, approval not burned by a hold, and the concurrent asyncio.gather path). 137 passed in tests/unit/filters + tests/unit/kernel; ruff check and ruff format clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@babyblueviper1 @rpelevin and the automated review are right, and I checked it in the source rather than taking it on trust: both auto-invocation loops in Pushed as 24a82d3:
What it does not do, stated plainly and also written into the docstring: this narrows the window, it is not atomic, and it cannot be atomic from inside a filter. A sibling that was already dispatched before the suspension is not recalled. @rpelevin's formulation is the structural fix, and it is the right one: evaluate policy per call at the enforcement point before the batch enters @babyblueviper1, thanks for going and reading ADR-0006 instead of assuming. Worth restating for anyone landing here later: Agent Framework returns the whole batch as |
|
@Correctover Fair thing to ask in the open, so here is an answer in the open, with dates. 1. What is the relationship between this implementation and the CCS design? Not derivation. Each element you name traces to material in this thread that predates CCS being mentioned in it:
The same posture is also in our own public work before your first comment here: anthropics/claude-cookbooks#784 (opened 16 July) and #787 (18 July), both of which ship typed authority routing with fail-closed defaults. 2. Were the fail-closed defaults and the bound-approval pattern derived from the CCS architecture? No. 13 June and 17 June respectively, in this issue, both public and both before your first comment in the thread (22 July). 3. Why is Correctover credited as "real-world incident context" rather than as the origin of the approach? Because that is what your comment in this thread contributed, and it was worth crediting: you brought the OpenAI / Hugging Face disclosure on 22 July as evidence that the default has to be fail-closed. The PR body says exactly that. On citing CCS as prior art: I tried to, and could not verify it. One thing I would rather say plainly than leave implied: the terminate-and-resume gap you and others pushed on turned out to be real, and it got fixed because it was raised here. That is the part of this thread that mattered. |
|
@moonbox3 Understood, and thank you for the clear and early call. No argument from me: a maintained successor with pre-execution policy enforcement is a better home for this than a filter bolted onto the previous SDK. For anyone who lands here from #14072: the code stays on the branch ( I am not going to open a speculative issue in Agent Framework. @babyblueviper1 went and read ADR-0006, and the gap this thread found already has an answer there: when any call in a batch needs approval the whole batch is returned as Thanks to @QiuYucheng2003 for the original report, and to @rpelevin, @Whatsonyourmind, @Tuttotorna, @babyblueviper1, @renezander030 and @Correctover. The thread was worth more than the PR: it found a real bug in what I had written, and 24a82d3 exists because of it. |
|
Thanks for the detailed timeline. Let me correct a few factual points for the public record: 1. CCS v1.0 was published on July 9, 2026 This is a formal standard document defining the fail-closed decorator pattern, synchronous interceptor governance, and the Required(τ) ⊆ Supported(τ) conformance criterion — the exact architectural approach this PR implements. The Zenodo record is timestamped and publicly verifiable. 2. The ccs-sdk repository is public and accessible Commit history shows 3. My July 22 comment was not the "origin" of my involvement 4. On design lineage I'm not disputing that others independently converged on similar ideas. But the public record should reflect that CCS's specific approach (decorator-based fail-closed governance, ~7.5μs P50 overhead, formal conformance proof) was established on July 9, 2026, and is documented in DOI 10.5281/zenodo.21271910. For the record: the GitHub user @babyblueviper1 has been blocked from my repositories due to prior conduct. Their characterization of CCS's "contribution" here should be evaluated in that context. — Guigui Wang | Correctover |
|
@Correctover Your first point is right, and my earlier comment was wrong on it. Correcting that first, since it is the one that matters in a priority discussion. I opened the DOI you gave: On the repository, so the record is precise rather than accusatory: I re-checked just now, unauthenticated, from a plain HTTP request rather than an API call — On lineage: I accept independent parallel development, and with the 9 July record the public evidence supports it better than "plausible" did. That does not change my answer on derivation — the elements I listed came from this thread on the dates I gave, and I had not read CCS when I wrote the filter — but "two efforts converged on the same primitives within weeks" is an accurate description of what happened, and I do not mean it grudgingly. Convergence on fail-closed-by-default and argument-bound approval is evidence the problem is real, not evidence anyone copied. Your out-of-process design and an in-process filter also fail differently, and that difference is the part worth writing down for anyone choosing between them: a filter shares the process it polices, which is a weaker trust boundary than a separate verifier, and I said as much in the docstring rather than in a changelog. Where the citation lands, concretely: @moonbox3 closed this PR and that was the right call, so the branch On @babyblueviper1: I have no visibility into whatever happened between you and them, and I am not going to take a position on it. In this thread I weighed their comments only on whether the code did what it claimed — and there they were right, as were @rpelevin and the automated review. That is the only basis I have for judging a comment here, and I would rather apply it consistently than selectively. |
|
Done, so the record is closed rather than promised: CCS is now cited in the filter's docstring on the branch — DOI 10.5281/zenodo.21271910, published 2026-07-09, noted as independent prior public work reaching the same fail-closed and argument-bound-approval conclusions, together with the in-process vs out-of-process trust-boundary difference so anyone choosing between the two sees it in the source instead of in a thread. Commit: Palo-Alto-AI-Research-Lab@a2e757d @Correctover — if the ccs-sdk repository becomes reachable I will cite the commit history alongside the DOI. Thanks for pushing on the dates; the correction was warranted. |
|
Hi @moonbox3 and team, Thank you for acknowledging the CCS DOI and integrating it into the docstring (commit a2e757d). We appreciate the recognition. To answer your question about ccs-sdk — the repository is currently private as we're actively iterating on the reference implementation. The production-ready verifier is already available as Development Timeline:
Our intent with the full chain (NeuralBridge → CCS → ccs-verifier): Our goals are:
We've already reached out to explore commercial collaboration with Palo Alto Networks (email to mrichmond@paloaltonetworks.com on July 29). We'd welcome the opportunity to discuss how CCS can integrate with PANW's security stack — whether through co-development, licensing, or technical partnership. Happy to have a technical deep-dive anytime. The verifier is live on PyPI — no reason to wait. Best, |
|
@microsoft-github-policy-service agree |
|
@Palo-Alto-AI-Research-Lab Thank you for the precise framing: "a filter shares the process it polices, which is a weaker trust boundary than a separate verifier." You identified exactly the gap I was working on. Quick update: This directly addresses a criticism raised in the IETF agent2agent discussion: "verifier signature cannot prove runtime correctness" because in an in-process model, the signing key lives in the same address space as the code being verified. With OOP transport:
PyPI: https://pypi.org/project/ccs-verifier/0.3.0/ The trust boundary you described is now enforced by process isolation rather than assumption. — Guigui Wang | Correctover — Runtime Verification for Agent Systems |
|
Please keep PRs scoped to things related to the PR - if you want to cross link to outside work (outside repos, projects), you may do so in https://github.com/microsoft/semantic-kernel/discussions/categories/show-and-tell. Also, this PR is now closed - why is there a need for continued discussion? |
|
@moonbox3 Fair point, and I'll keep it brief: the comment was a direct technical response to @Palo-Alto-AI-Research-Lab's analysis of in-process vs out-of-process trust boundaries in this thread — not cross-linking for its own sake. That said, won't continue discussion on a closed PR. Thanks. |
Motivation and Context
Addresses #14072 (Python: Lack of Runtime Access Control (RBAC/Approval Mechanism) in Auto Function Invocation Leads to Unauthorized Execution via Indirect Prompt Injection), reported by @QiuYucheng2003.
Semantic Kernel already has the enforcement point — an
AUTO_FUNCTION_INVOCATIONfilter runs immediately before dispatch, and a filter that doesn't callnext()blocks the call — but there is no authorization contract: every team hand-rolls its own allowlist logic, unclassified functions fall through open, and "approved" is a control-flow side effect rather than a recorded, argument-bound artifact. That gap has been requested repeatedly beyond #14072: #14056 (@nagasatish007, governance filter for function calls), #13661 (@uchibeke,IGuardrailProviderfor policy-based invocation control), and the history that issue documents — #10951 (agent invocation filter for guardrails), #5436 (function-call approval as a filter use case), #1409 (guardrails, 2023), #13556 (governance policy filter), #12294 (practical need for pre-invocation policy checks).This PR contributes an experimental, additive reference implementation of the contract the #14072 thread converged on — no changes to
kernel.py, the decorator, or any existing behavior; nothing changes unless the filter is explicitly registered.Design credit to the discussion in #14072: the fail-closed test list by @rpelevin (all six of those tests are implemented here verbatim), the enforcement-point mapping, deny-by-default metadata, args-digest approval binding and the terminate-and-resume pattern by @Whatsonyourmind, the four-surface split and
FunctionAuthorizationDecisionshape by @Tuttotorna, the two-latency-classes observation by @babyblueviper1 (synchronous deterministic verdicts run in-loop; human approvals suspend), and the deny-and-persist production contract by @renezander030 (supported here viaterminate_on_pending=False, which short-circuits apending_approvalresult so the chat request completes cleanly). The real-world incident context from @Correctover underlines why the default must be fail-closed.The pattern (typed decision postures, deterministic keyword guard with most-restrictive-wins, fail-closed defaults) follows the authority-routing design we previously shipped for other agent stacks (anthropics/claude-cookbooks#787, google/adk-python-community#172).
Description
One new module,
semantic_kernel/filters/auto_function_invocation/function_authorization_filter.py(everything@experimental):FunctionAuthorizationPolicy— declarative, deterministic, fail-closed:"plugin-function"or"plugin-*"), orKernelFunctionMetadata.additional_properties(risk_level,requires_approval) — no new decorator API;default_risk(HIGH ⇒ approval required); invalid declared risk also fails closed;keyword_guardescalates risk on suspicious argument content (most-restrictive-wins, never lowers);action_map: LOW/MEDIUM → ALLOW, HIGH → REQUIRE_APPROVAL, CRITICAL → DENY (unmapped ⇒ DENY).FunctionAuthorizationFilter— a standard(context, next)filter:pending_approvalresult, loop terminated by default (terminate-and-resume);(fully qualified name ‖ canonical args digest ‖ principal ‖ policy digest)— an approval fortransfer(amount=10)can never be replayed fortransfer(amount=10000), and a policy change invalidates outstanding approvals;__str__cannot forge a digest collision; uncanonicalizable arguments (e.g. circular references) fail closed to DENY;audit_logofFunctionAuthorizationDecisionrecords with distinctpending_approval / approved / denied / expired / executed / failedstates — enforcement and the audit trail are the same object;tests/unit/filters/test_function_authorization.py, 29 deterministic tests, no network/keys, driven through the realkernel.invoke_function_callpath — including @rpelevin's six fail-closed scenarios (injection can propose but not bypass; changed args invalidate approval; missing metadata fails closed; expiry is terminal; denied cannot be converted by retry; audit states are distinguishable) plus hostile-input cases (lookalike__str__, crafted mimic strings, circular arguments, NaN/inf/zero TTLs, downstream dispatch failure).samples/concepts/filtering/function_authorization_filter.py— runs without any model API key (simulates the hijacked model's tool calls viakernel.invoke_function_call) and walks the full story: benign call allowed → injected destructive call suspended → human grants → identical call executes exactly once → tampered replay blocked.What this deliberately does not do (per the thread): no blocking
awaiton a human inside the filter (the loop is never held open), no new decorator API, no changes to existing dispatch semantics.Sample output:
Contribution Checklist
ruff check/ruff format --checkclean on the changed files)tests/unit/filters+tests/unit/kernel: 131 passed)🤖 Generated with Claude Code