Python: Enforce approval_mode in Claude and GitHub Copilot agents (#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 #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>
This commit is contained in:
Eduard van Valkenburg
2026-05-01 16:11:28 +02:00
committed by GitHub
Unverified
parent 626b418622
commit c1cc6ee6df
6 changed files with 783 additions and 2 deletions
@@ -0,0 +1,129 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Claude Agent with Function Approval
This sample demonstrates how to enforce ``approval_mode="always_require"`` on a
``FunctionTool`` when using ``ClaudeAgent``. Because the Claude Agent SDK runs
its own tool-calling loop, the standard agent-framework approval round-trip
(``FunctionApprovalRequestContent`` → ``FunctionApprovalResponseContent``) is
not available — the agent instead awaits an ``on_function_approval`` callback
inside the tool handler before executing the tool.
Key points:
- ``on_function_approval`` is set on ``ClaudeAgentOptions`` and receives a
``FunctionCallContent`` describing the pending call. It must return ``True``
to allow execution or ``False`` to deny it. Async callbacks are also
supported.
- If no callback is configured, calls to ``always_require`` tools are denied
by default and the model receives an explanatory error so it can react.
- This callback is independent of Claude's built-in ``permission_mode`` /
``can_use_tool`` features, which gate the SDK's own shell/file actions.
Environment variables:
- ANTHROPIC_API_KEY: Your Anthropic API key.
"""
import asyncio
from random import randrange
from typing import Annotated
from agent_framework import Content, tool
from agent_framework.anthropic import ClaudeAgent
from dotenv import load_dotenv
load_dotenv()
# Always-require tool: execution must be gated by on_function_approval.
@tool(approval_mode="always_require")
def get_weather_detail(location: Annotated[str, "The city and state, e.g. San Francisco, CA"]) -> str:
"""Get a detailed weather report for a location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
return (
f"The weather in {location} is {conditions[randrange(0, len(conditions))]} "
f"with a high of {randrange(10, 30)}C and humidity of 88%."
)
def prompt_for_approval(call: Content) -> bool:
"""Synchronous approval prompt.
The callback receives a ``FunctionCallContent`` so the operator can review
the tool name and arguments before deciding. Returning ``True`` allows the
call; returning ``False`` denies it and a tool-error is returned to the
model.
"""
print(f"\n[Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
response = input("Approve this tool call? (y/n): ").strip().lower()
return response in ("y", "yes")
async def prompt_for_approval_async(call: Content) -> bool:
"""Async approval prompt.
Use an async callback when approval requires I/O (e.g. an HTTP call to a
review service or queueing the request to a UI). ``input()`` is wrapped
with ``asyncio.to_thread`` so the event loop is not blocked.
"""
print(f"\n[Function Approval Request - async]\n Tool: {call.name}\n Arguments: {call.arguments}")
response = await asyncio.to_thread(input, "Approve this tool call? (y/n): ")
return response.strip().lower() in ("y", "yes")
async def run_with_sync_callback() -> None:
print("\n=== Claude Agent: synchronous approval callback ===")
agent = ClaudeAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
default_options={"on_function_approval": prompt_for_approval},
)
async with agent:
query = "Give me the detailed weather for Seattle."
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result.text}")
async def run_with_async_callback() -> None:
print("\n=== Claude Agent: asynchronous approval callback ===")
agent = ClaudeAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
default_options={"on_function_approval": prompt_for_approval_async},
)
async with agent:
query = "Give me the detailed weather for Tokyo."
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result.text}")
async def run_without_callback() -> None:
"""Default-deny demonstration.
With no ``on_function_approval`` configured, the always-require tool is
refused and the model receives an explanatory error, so it can apologise
or try a different approach instead of silently failing.
"""
print("\n=== Claude Agent: no callback configured (deny by default) ===")
agent = ClaudeAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
)
async with agent:
query = "Give me the detailed weather for Paris."
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result.text}")
async def main() -> None:
print("=== Claude Agent: Function approval enforcement ===")
await run_with_sync_callback()
await run_with_async_callback()
await run_without_callback()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,131 @@
# Copyright (c) Microsoft. All rights reserved.
"""
GitHub Copilot Agent with Function Approval
This sample demonstrates how to enforce ``approval_mode="always_require"`` on a
``FunctionTool`` when using ``GitHubCopilotAgent``. Because the Copilot CLI
runs its own tool-calling loop, the standard agent-framework approval
round-trip (``FunctionApprovalRequestContent`` → ``FunctionApprovalResponseContent``)
is not available — the agent instead awaits an ``on_function_approval``
callback inside the tool handler before executing the tool.
Key points:
- ``on_function_approval`` is set on ``GitHubCopilotOptions`` and receives a
``FunctionCallContent`` describing the pending call. It must return ``True``
to allow execution or ``False`` to deny it. Async callbacks are also
supported.
- If no callback is configured, calls to ``always_require`` tools are denied
by default and the model receives an explanatory error so it can react.
- This callback is independent of ``on_permission_request``, which gates the
Copilot SDK's *built-in* shell/file actions; ``on_function_approval`` gates
agent-framework ``FunctionTool`` calls.
Environment variables (optional):
- GITHUB_COPILOT_CLI_PATH: Path to the Copilot CLI executable.
- GITHUB_COPILOT_MODEL: Model to use.
"""
import asyncio
from random import randrange
from typing import Annotated
from agent_framework import Content, tool
from agent_framework.github import GitHubCopilotAgent
from dotenv import load_dotenv
load_dotenv()
# Always-require tool: execution must be gated by on_function_approval.
@tool(approval_mode="always_require")
def get_weather_detail(location: Annotated[str, "The city and state, e.g. San Francisco, CA"]) -> str:
"""Get a detailed weather report for a location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
return (
f"The weather in {location} is {conditions[randrange(0, len(conditions))]} "
f"with a high of {randrange(10, 30)}C and humidity of 88%."
)
def prompt_for_approval(call: Content) -> bool:
"""Synchronous approval prompt.
The callback receives a ``FunctionCallContent`` so the operator can review
the tool name and arguments before deciding. Returning ``True`` allows the
call; returning ``False`` denies it and a tool-error is returned to the
model.
"""
print(f"\n[Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
response = input("Approve this tool call? (y/n): ").strip().lower()
return response in ("y", "yes")
async def prompt_for_approval_async(call: Content) -> bool:
"""Async approval prompt.
Use an async callback when approval requires I/O (e.g. an HTTP call to a
review service or queueing the request to a UI). ``input()`` is wrapped
with ``asyncio.to_thread`` so the event loop is not blocked.
"""
print(f"\n[Function Approval Request - async]\n Tool: {call.name}\n Arguments: {call.arguments}")
response = await asyncio.to_thread(input, "Approve this tool call? (y/n): ")
return response.strip().lower() in ("y", "yes")
async def run_with_sync_callback() -> None:
print("\n=== GitHub Copilot Agent: synchronous approval callback ===")
agent = GitHubCopilotAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
default_options={"on_function_approval": prompt_for_approval},
)
async with agent:
query = "Give me the detailed weather for Seattle."
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result}")
async def run_with_async_callback() -> None:
print("\n=== GitHub Copilot Agent: asynchronous approval callback ===")
agent = GitHubCopilotAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
default_options={"on_function_approval": prompt_for_approval_async},
)
async with agent:
query = "Give me the detailed weather for Tokyo."
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result}")
async def run_without_callback() -> None:
"""Default-deny demonstration.
With no ``on_function_approval`` configured, the always-require tool is
refused and the model receives an explanatory error, so it can apologise
or try a different approach instead of silently failing.
"""
print("\n=== GitHub Copilot Agent: no callback configured (deny by default) ===")
agent = GitHubCopilotAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
)
async with agent:
query = "Give me the detailed weather for Paris."
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result}")
async def main() -> None:
print("=== GitHub Copilot Agent: Function approval enforcement ===")
await run_with_sync_callback()
await run_with_async_callback()
await run_without_callback()
if __name__ == "__main__":
asyncio.run(main())