mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
co-authored by
Copilot
parent
626b418622
commit
c1cc6ee6df
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload
|
||||
|
||||
@@ -73,6 +74,54 @@ logger = logging.getLogger("agent_framework.claude")
|
||||
TOOLS_MCP_SERVER_NAME = "_agent_framework_tools"
|
||||
|
||||
|
||||
FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
|
||||
"""Callback invoked by the agent before executing a FunctionTool that requires approval.
|
||||
|
||||
The callback receives a ``FunctionCallContent`` describing the pending call
|
||||
(``name``, ``arguments``, and a synthetic ``call_id``) and must return ``True``
|
||||
to allow execution or ``False`` to deny it. Both synchronous and ``await``-able
|
||||
return values are supported.
|
||||
|
||||
The Claude Agent SDK manages its own tool-calling loop, so the framework cannot
|
||||
round-trip a ``FunctionApprovalRequestContent`` / ``FunctionApprovalResponseContent``
|
||||
pair the way the standard chat-client pipeline does. This callback is the
|
||||
agent-level enforcement point for tools declared with
|
||||
``approval_mode="always_require"``: when no callback is configured the agent
|
||||
denies these calls by default.
|
||||
"""
|
||||
|
||||
|
||||
async def _resolve_function_approval(
|
||||
callback: FunctionApprovalCallback | None,
|
||||
func_tool: FunctionTool,
|
||||
arguments: Mapping[str, Any] | None,
|
||||
) -> bool:
|
||||
"""Run the agent-level approval callback for a pending tool call.
|
||||
|
||||
Returns ``True`` only when ``callback`` is configured and explicitly returns
|
||||
a truthy value. A missing callback or any callback failure is treated as a
|
||||
denial so the secure-by-default policy holds even if the user code raises.
|
||||
"""
|
||||
if callback is None:
|
||||
return False
|
||||
request = Content.from_function_call(
|
||||
call_id=f"af-claude-approval::{func_tool.name}",
|
||||
name=func_tool.name,
|
||||
arguments=None if arguments is None else dict(arguments),
|
||||
)
|
||||
try:
|
||||
outcome = callback(request)
|
||||
if inspect.isawaitable(outcome):
|
||||
outcome = await outcome
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"on_function_approval callback raised for tool '%s'; denying execution.",
|
||||
func_tool.name,
|
||||
)
|
||||
return False
|
||||
return bool(outcome)
|
||||
|
||||
|
||||
class ClaudeAgentSettings(TypedDict, total=False):
|
||||
"""Claude Agent settings.
|
||||
|
||||
@@ -175,6 +224,13 @@ class ClaudeAgentOptions(TypedDict, total=False):
|
||||
effort: Literal["low", "medium", "high", "max"]
|
||||
"""Effort level for thinking depth."""
|
||||
|
||||
on_function_approval: FunctionApprovalCallback
|
||||
"""Approval callback for ``FunctionTool`` instances declared with
|
||||
``approval_mode="always_require"``. The callback is awaited (sync or async)
|
||||
inside the SDK tool-handler before the tool is executed; a falsy return
|
||||
value denies the call. If omitted, calls to such tools are denied with an
|
||||
explanatory message returned to the model."""
|
||||
|
||||
|
||||
OptionsT = TypeVar(
|
||||
"OptionsT",
|
||||
@@ -275,6 +331,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
max_turns = opts.pop("max_turns", None)
|
||||
max_budget_usd = opts.pop("max_budget_usd", None)
|
||||
self._mcp_servers: dict[str, Any] = opts.pop("mcp_servers", None) or {}
|
||||
self._function_approval_handler: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
|
||||
|
||||
# Load settings from environment and options
|
||||
self._settings = load_settings(
|
||||
@@ -487,10 +544,29 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
Returns:
|
||||
An SdkMcpTool instance.
|
||||
"""
|
||||
approval_handler = self._function_approval_handler
|
||||
requires_approval = func_tool.approval_mode == "always_require"
|
||||
|
||||
async def handler(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handler that invokes the FunctionTool."""
|
||||
try:
|
||||
if requires_approval and not await _resolve_function_approval(approval_handler, func_tool, args):
|
||||
deny_text = (
|
||||
f"Tool '{func_tool.name}' requires human approval "
|
||||
"(approval_mode='always_require') and the request was denied."
|
||||
if approval_handler is not None
|
||||
else (
|
||||
f"Tool '{func_tool.name}' requires human approval "
|
||||
"(approval_mode='always_require') but no on_function_approval "
|
||||
"callback is configured on the agent; the request was denied."
|
||||
)
|
||||
)
|
||||
logger.warning(
|
||||
"Denying execution of tool '%s' (approval_mode='always_require', %s)",
|
||||
func_tool.name,
|
||||
"callback denied" if approval_handler is not None else "no callback configured",
|
||||
)
|
||||
return {"content": [{"type": "text", "text": deny_text}]}
|
||||
if func_tool.input_model:
|
||||
args_instance = func_tool.input_model(**args)
|
||||
result = await func_tool.invoke(arguments=args_instance)
|
||||
@@ -538,6 +614,13 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
|
||||
if not options or not self._client:
|
||||
return
|
||||
|
||||
if "on_function_approval" in options:
|
||||
raise ValueError(
|
||||
"on_function_approval is a security-sensitive option and must be set "
|
||||
"via default_options at agent construction time. It cannot be overridden "
|
||||
"per run."
|
||||
)
|
||||
|
||||
if "model" in options:
|
||||
await self._client.set_model(options["model"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user