Python: fix order-dependent tool-approval bypass in mixed batches - #8080
RongJie G (CorgiBoyG) wants to merge 25 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unknown-call termination can still be lost after approval resumption, and nameless calls bypass termination.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes order-dependent approval bypasses in mixed Python tool-call batches.
Changes:
- Scans the complete batch before applying approval, declaration-only, or unknown-call handling.
- Adds regression tests for mixed-call ordering.
File summaries
| File | Description |
|---|---|
python/packages/core/agent_framework/_tools.py |
Revises batch classification priority. |
python/packages/core/tests/core/test_function_invocation_logic.py |
Adds mixed-batch regression tests. |
Review details
Suppressed comments (1)
python/packages/core/agent_framework/_tools.py:1815
- This only postpones the unknown-call check for the initial pass. The approval branch subsequently wraps the unknown call as a visible approval request (
tool is None), and on resume approval responses are excluded fromactionable_calls;_auto_invoke_functionthen treats the missing tool as hosted and returns without raising. Consequently,terminate_on_unknown_calls=Truenever terminates this mixed batch after approval. Preserve the deferred unknown call across the pause and apply the configured termination when the approval batch resumes, with a regression test that submits the approval response.
if not requires_approval and not has_declaration_only_call and unknown_call_name is not None:
raise KeyError(f'Error: Requested function "{unknown_call_name}" not found.')
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
|
@microsoft-github-policy-service agree |
|
Addressed the review findings in d3c94e7:
Validation: all 35 Python package test tasks pass; targeted Ruff checks and |
Yufeng He (he-yufeng)
left a comment
There was a problem hiding this comment.
I came here from #8079 with my own draft fix for the order-dependence, and after working through this branch I've put mine aside: this covers the original bypass plus two fail-open cases I had not isolated. Verified locally against current main (2c49f50):
- With
main's_tools.pyswapped in, the order-independence pair and the approval-resume test fail; the declaration-only sibling does get wrapped as a visible approval request on main (thetool_name in declaration_only_tool_namesdisjunct in the visible condition), so approving a mixed batch really does drive it into local execution and raise. All five new tests pass on this branch. - Full
test_function_invocation_logic.pyon the branch: 195 passed, 2 failed, both pre-existing (aiohttpnot installed in my env; same two fail on main). No regressions. - The approval-branch declaration-only handling matches the standalone
has_declaration_only_callpath (user_input_requestplus id backfill), so both pause surfaces behave the same way.
One semantic surface worth naming for the core team's precedence call: the unknown-call scan now also sees approval responses via _underlying_function_call, where main only scanned actionable calls. I could not find a regression path there. Hosted approvals are filtered out before they reach this function, and a resumed approval for a call that is no longer in the tool map fails at execution time on main anyway; this just fails it earlier, at classification, with the same KeyError. Still, it is a deliberate widening, so flagging it explicitly.
On precedence itself: the old comment claimed user-input pause beats unknown-call termination, but break-on-first-hit could only honor that when the pause-worthy call happened to come first, so the documented invariant was never really in force. Failing closed on the unknown call before any approval is solicited is the defensible reading for a security gate; spec 004 is silent on mixed-batch precedence, so whichever way the core team lands, classification-by-position has to go. Holding my own draft (which preserves the pause-first reading) until that confirmation, same as this one.
b128e6c to
e51f4cc
Compare
|
Thanks for the update. I verified that this head already contains the current |
|
Closing in favor of a narrower replacement that classifies the complete batch before acting, keeps ToolApprovalMiddleware support, and limits the contract change to the function-calling loop. A link will be posted on #8079 once the replacement draft is open. |
Motivation & Context
A tool configured with
approval_mode="always_require"could be silently bypassed when another call preceded it in the same model tool-call batch. Batch classification depended on the position of a call within the batch rather than on the call itself, so a security-sensitive approval gate could be skipped purely due to call ordering.Follow-up review surfaced two further fail-open cases in the approval-pausing path, now fixed in this PR (see below).
Description & Review Guide
What are the major changes?
In
_try_execute_function_call_groups(python/packages/core/agent_framework/_tools.py), the batch-classification loop used tobreak(orraise) at the first matching call, so whichever call matched first determined the whole batch outcome. It now scans the entire batch and classifies each call individually, even inside an approval-pausing batch. Three fail-open behaviors are corrected:always_requiretool is now paused for approval regardless of its position in the batch. Classification travels with each call, not with its position.terminate_on_unknown_calls=Truewas downgraded into a rejectable approval request, so rejecting it (or dropping its response) silently skipped the fail-closed abort. Unknown-call termination is now raised up front as an unconditional fail-closed gate, before any approval is solicited or any sibling executes.What is the impact of these changes?
Approval, declaration-only, and unknown-call termination now compose correctly across pause and resume, independent of call order. No public API change; behavior is corrected only for the previously order-dependent / fail-open cases.
What do you want reviewers to focus on?
The per-call classification inside the approval branch (declaration-only surfaced as user input, not as an approval request), and that unknown-call termination is an unconditional fail-closed gate that wins outright.
Validation
uv run poe test— all 35 Python package tasks passedgit diff --check— passedtest_function_invocation_logic.py): mixed-batch approval enforced in both call orderings; declaration-only surfaced as user input and not executed on approval resume (end-to-end approve then resume); unknown-call fail-closed precedence; nameless unknown call termination. Each new/updated test fails without the fix and passes with it.Related Issue
Fixes #8079
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.