Skip to content

Python: Enforce approval_mode in FunctionTool.invoke() - #5494

Closed
chetantoshniwal wants to merge 1 commit into
microsoft:mainfrom
chetantoshniwal:fix/approval-mode-bypass-109288-upstream
Closed

Python: Enforce approval_mode in FunctionTool.invoke()#5494
chetantoshniwal wants to merge 1 commit into
microsoft:mainfrom
chetantoshniwal:fix/approval-mode-bypass-109288-upstream

Conversation

@chetantoshniwal

Copy link
Copy Markdown
Contributor

Motivation and Context

FunctionTool.invoke() previously ignored approval_mode='always_require', allowing direct callers (e.g., Claude/Copilot integrations) to bypass the human approval gate that only existed in _try_execute_function_calls().

Description

  • Add ToolApprovalRequiredException to exceptions.py
  • Add approval_mode check at the top of FunctionTool.invoke()
  • Add _approved parameter (default False) to invoke() signature and overloads
  • Pass _approved=True from _auto_invoke_function() call sites (both direct and middleware pipeline paths)
  • Export ToolApprovalRequiredException from agent_framework package
  • Add comprehensive tests for the new guard

Contribution Checklist

  • The code builds clean without any errors or warnings
  • The PR follows the Contribution Guidelines
  • All unit tests pass, and I have added new tests where possible
  • Is this a breaking change? If yes, add "[BREAKING]" prefix to the title of the PR.

FunctionTool.invoke() previously ignored approval_mode='always_require',
allowing direct callers (e.g., Claude/Copilot integrations) to bypass
the human approval gate that only existed in _try_execute_function_calls().

Changes:
- Add ToolApprovalRequiredException to exceptions.py
- Add approval_mode check at the top of FunctionTool.invoke()
- Add _approved parameter (default False) to invoke() signature and overloads
- Pass _approved=True from _auto_invoke_function() call sites
  (both direct and middleware pipeline paths)
- Export ToolApprovalRequiredException from agent_framework package
- Add comprehensive tests for the new guard

Based on upstream microsoft/agent-framework main branch.
Fixes MSRC Case 109288 (VULN-176822)

Co-authored-by: Azure SRE Agent <noreply@microsoft.com>
Copilot AI review requested due to automatic review settings April 26, 2026 06:40
@moonbox3 Evan Mattson (moonbox3) added the python Usage: [Issues, PRs], Target: Python label Apr 26, 2026
@github-actions github-actions Bot changed the title Fix CWE-862: Enforce approval_mode in FunctionTool.invoke() Python: Fix CWE-862: Enforce approval_mode in FunctionTool.invoke() Apr 26, 2026
@moonbox3

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/core/agent_framework
   _tools.py9848391%219–220, 395, 397, 410, 435–437, 445, 463, 477, 484, 491, 514, 516, 523, 531, 666, 706–708, 710, 716, 768–770, 795, 821, 825, 863–865, 869, 891, 1033–1039, 1075, 1087, 1089, 1091, 1094–1097, 1118, 1122, 1126, 1140–1142, 1483, 1594–1600, 1729, 1733, 1779, 1840–1841, 1956, 1976, 1978, 2034, 2097, 2269–2270, 2290, 2346–2347, 2485–2486, 2553, 2558, 2565
   exceptions.py680100% 
TOTAL29870347988% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
6028 30 💤 0 ❌ 0 🔥 1m 37s ⏱️

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Code Review

Reviewers: 4 | Confidence: 92%

✗ Correctness

The core approval-gate logic is correct: invoke() properly checks approval_mode == 'always_require' before the _approved flag, both auto-invocation paths pass _approved=True, and the new exception inherits from ToolException. Two issues found: (1) ToolApprovalRequiredException is inserted out of alphabetical order in __all__, breaking the clearly maintained sorted convention; (2) a test uses approval_mode="auto" which is not a valid ApprovalMode value (Literal["always_require", "never_require"] per line 93 of _tools.py), testing behavior on an undeclared mode.

✓ Security Reliability

This PR adds a defense-in-depth approval gate to FunctionTool.invoke() that raises ToolApprovalRequiredException when a tool with approval_mode='always_require' is called without the _approved flag. The real approval gate lives in _try_execute_function_calls (lines 1641-1672), which intercepts approval-required calls before execution and returns approval requests to the user. The _approved flag on invoke() is a secondary guard against direct calers bypassing the pipeline. The implementation is sound: the auto-invoke pipeline correctly passes _approved=True in both the direct (line 1522 in diff) and middleware (line 1554 in diff) paths, and _try_execute_function_calls already handles the approval flow upstream. No security or reliability issues found.

✓ Test Coverage

The new approval gate mechanism on FunctionTool.invoke() is well-tested at the unit level, with tests covering the blocked case, the _approved=True bypass, default/auto modes, and the call escape hatch. The pipeline integration path (_auto_invoke_function passing _approved=True) is already covered by existing tests in test_function_invocation_logic.py (e.g., test_approved_function_call_successful_execution). No bugs or missing critical coverage found. One minor suggestion for improving test robustness.

✓ Design Approach

The change does not enforce approval at the real tool execution boundary. It adds a new _approved boolean to the public invoke() API, but that flag is entirely caller-controlled and the underlying FunctionTool.__call__ path still executes approval_mode="always_require" tools without any check. Because the framework already has a first-class approval flow based on function_approval_request/function_approval_response content, this patch looks like a symptom-level guard one call path rather than a robust approval model.

Suggestions

  • Enforce the approval policy in the shared execution path (_tools.py:511-538) rather than only in invoke(). Currently FunctionTool.__call__ executes approval_mode='always_require' tools without any check, so alternative integrations can accidentally skip the approval contract.

Automated review by chetantoshniwal's agents

@eavanvalkenburg

Copy link
Copy Markdown
Member

I would prefer that we fix this in Claude and Copilot, because this makes it seem like a fix, that is not a actual fix, this line in the docstring gives away the game, so this adds a new parameter which can be spoofed with ease once you have access to code:

Callers outside the pipeline must obtain human approval and pass
``_approved=True`` to execute tools with ``approval_mode='always_require'``.

since there is nothing preventing you from calling with _approved=True without obtaining human approval, so let's fix Claude/Copilot and not add parameters that don't actually fix anything.

@moonbox3 Evan Mattson (moonbox3) changed the title Python: Fix CWE-862: Enforce approval_mode in FunctionTool.invoke() Python: Enforce approval_mode in FunctionTool.invoke() Apr 27, 2026
Eduard van Valkenburg (eavanvalkenburg) added a commit to eavanvalkenburg/agent-framework that referenced this pull request May 1, 2026
Tools declared with approval_mode="always_require" were bypassed by the
ClaudeAgent and GitHubCopilotAgent because their SDK-managed tool-calling
loops invoke FunctionTool.invoke() directly via package-supplied handlers,
skipping the standard _try_execute_function_calls approval gate.

Per discussion on microsoft#5494, the fix lives in the agents (not in FunctionTool):
any flag added to the tool itself can be spoofed by code with the same
level of access, so the security boundary is the agent that owns the
tool-calling loop.

- Add on_function_approval option to ClaudeAgentOptions and
  GitHubCopilotOptions. Callback receives a FunctionCallContent describing
  the pending call and returns bool (sync or async).
- Gate FunctionTool.invoke() inside each agent's existing tool-handler
  closure when approval_mode == "always_require". Default policy is deny;
  callbacks that raise also deny safely.
- Deny path returns a tool-error to the model (Claude: text content;
  Copilot: ToolResult(result_type="failure", error="approval_denied"))
  so the LLM can react gracefully instead of silently failing.
- Tests for both agents covering: deny by default, sync False, sync True,
  async True, callback-raises -> deny, no-op for never_require tools.
- Samples demonstrating sync, async, and deny-by-default flows for both
  agents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Ben Thomas (alliscode) pushed a commit to alliscode/agent-framework that referenced this pull request May 1, 2026
…crosoft#5562)

* Python: Enforce approval_mode in Claude and GitHub Copilot agents

Tools declared with approval_mode="always_require" were bypassed by the
ClaudeAgent and GitHubCopilotAgent because their SDK-managed tool-calling
loops invoke FunctionTool.invoke() directly via package-supplied handlers,
skipping the standard _try_execute_function_calls approval gate.

Per discussion on microsoft#5494, the fix lives in the agents (not in FunctionTool):
any flag added to the tool itself can be spoofed by code with the same
level of access, so the security boundary is the agent that owns the
tool-calling loop.

- Add on_function_approval option to ClaudeAgentOptions and
  GitHubCopilotOptions. Callback receives a FunctionCallContent describing
  the pending call and returns bool (sync or async).
- Gate FunctionTool.invoke() inside each agent's existing tool-handler
  closure when approval_mode == "always_require". Default policy is deny;
  callbacks that raise also deny safely.
- Deny path returns a tool-error to the model (Claude: text content;
  Copilot: ToolResult(result_type="failure", error="approval_denied"))
  so the LLM can react gracefully instead of silently failing.
- Tests for both agents covering: deny by default, sync False, sync True,
  async True, callback-raises -> deny, no-op for never_require tools.
- Samples demonstrating sync, async, and deny-by-default flows for both
  agents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review: preserve empty arg dicts, reject runtime approval override

- _resolve_function_approval no longer collapses {} into None when building
  the FunctionCallContent passed to the callback (Claude + Copilot).
- Claude _apply_runtime_options and Copilot _run_impl/_stream_updates now
  raise ValueError if on_function_approval is supplied via per-run options,
  instead of silently ignoring it. Approval policy must be set at agent
  construction time.
- Drop unnecessary # type: ignore[attr-defined] on Content.name/.arguments
  in samples (Content is a unified class with both attributes defined).
- Add regression tests for the new runtime-options validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* warning when non callback handler and approval needed

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants