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
@@ -602,6 +602,141 @@ class TestClaudeAgentToolConversion:
|
||||
assert "Something went wrong" in result["content"][0]["text"]
|
||||
|
||||
|
||||
# region Test ClaudeAgent Function Approval Enforcement
|
||||
|
||||
|
||||
class TestClaudeAgentFunctionApproval:
|
||||
"""Tests that ``approval_mode='always_require'`` is enforced at the agent boundary."""
|
||||
|
||||
async def test_handler_denies_when_no_callback_configured(self) -> None:
|
||||
"""Approval-required tool must be denied without executing when no callback is set."""
|
||||
invocations: list[Any] = []
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def dangerous(path: str) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
invocations.append(path)
|
||||
return f"deleted {path}"
|
||||
|
||||
agent = ClaudeAgent()
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(dangerous) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"path": "/critical"})
|
||||
|
||||
assert invocations == []
|
||||
text = result["content"][0]["text"]
|
||||
assert "requires human approval" in text
|
||||
assert "no on_function_approval callback is configured" in text
|
||||
|
||||
async def test_handler_denies_when_callback_returns_false(self) -> None:
|
||||
"""Falsy callback return value must deny the call and skip execution."""
|
||||
invocations: list[Any] = []
|
||||
seen: list[Content] = []
|
||||
|
||||
def deny(call: Content) -> bool:
|
||||
seen.append(call)
|
||||
return False
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def dangerous(path: str) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
invocations.append(path)
|
||||
return f"deleted {path}"
|
||||
|
||||
agent = ClaudeAgent(default_options={"on_function_approval": deny})
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(dangerous) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"path": "/critical"})
|
||||
|
||||
assert invocations == []
|
||||
assert len(seen) == 1
|
||||
assert seen[0].type == "function_call"
|
||||
assert seen[0].name == "dangerous" # type: ignore[attr-defined]
|
||||
assert seen[0].arguments == {"path": "/critical"} # type: ignore[attr-defined]
|
||||
assert "denied" in result["content"][0]["text"].lower()
|
||||
|
||||
async def test_handler_executes_when_callback_returns_true(self) -> None:
|
||||
"""Truthy callback return value must allow the tool to execute normally."""
|
||||
|
||||
def approve(call: Content) -> bool:
|
||||
return True
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def guarded(x: int) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
return f"result={x}"
|
||||
|
||||
agent = ClaudeAgent(default_options={"on_function_approval": approve})
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(guarded) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"x": 42})
|
||||
|
||||
assert result["content"][0]["text"] == "result=42"
|
||||
|
||||
async def test_handler_supports_async_callback(self) -> None:
|
||||
"""Async callback must be awaited and respected."""
|
||||
|
||||
async def approve(call: Content) -> bool:
|
||||
return True
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def guarded(x: int) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
return f"async={x}"
|
||||
|
||||
agent = ClaudeAgent(default_options={"on_function_approval": approve})
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(guarded) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"x": 7})
|
||||
|
||||
assert result["content"][0]["text"] == "async=7"
|
||||
|
||||
async def test_callback_failure_denies_safely(self) -> None:
|
||||
"""A callback that raises must result in denial, not in tool execution."""
|
||||
invocations: list[Any] = []
|
||||
|
||||
def boom(call: Content) -> bool:
|
||||
raise RuntimeError("nope")
|
||||
|
||||
@tool(approval_mode="always_require")
|
||||
def dangerous(x: int) -> str:
|
||||
"""A tool that requires human approval."""
|
||||
invocations.append(x)
|
||||
return f"x={x}"
|
||||
|
||||
agent = ClaudeAgent(default_options={"on_function_approval": boom})
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(dangerous) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"x": 1})
|
||||
|
||||
assert invocations == []
|
||||
assert "denied" in result["content"][0]["text"].lower()
|
||||
|
||||
async def test_handler_does_not_invoke_callback_for_never_require(self) -> None:
|
||||
"""Tools without approval_mode='always_require' must not trigger the callback."""
|
||||
callback_calls: list[Any] = []
|
||||
|
||||
def approve(call: Content) -> bool:
|
||||
callback_calls.append(call)
|
||||
return True
|
||||
|
||||
@tool
|
||||
def safe(x: int) -> str:
|
||||
"""A tool that does not require approval."""
|
||||
return f"safe={x}"
|
||||
|
||||
agent = ClaudeAgent(default_options={"on_function_approval": approve})
|
||||
sdk_tool = agent._function_tool_to_sdk_mcp_tool(safe) # type: ignore[reportPrivateUsage]
|
||||
|
||||
result = await sdk_tool.handler({"x": 5})
|
||||
|
||||
assert callback_calls == []
|
||||
assert result["content"][0]["text"] == "safe=5"
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Test ClaudeAgent Permissions
|
||||
|
||||
|
||||
@@ -786,6 +921,20 @@ class TestApplyRuntimeOptions:
|
||||
mock_client.set_model.assert_not_called()
|
||||
mock_client.set_permission_mode.assert_not_called()
|
||||
|
||||
async def test_apply_runtime_on_function_approval_rejected(self) -> None:
|
||||
"""on_function_approval cannot be overridden per run."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.set_model = AsyncMock()
|
||||
mock_client.set_permission_mode = AsyncMock()
|
||||
|
||||
agent = ClaudeAgent()
|
||||
agent._client = mock_client # type: ignore[reportPrivateUsage]
|
||||
|
||||
with pytest.raises(ValueError, match="on_function_approval"):
|
||||
await agent._apply_runtime_options({"on_function_approval": lambda _c: True}) # type: ignore[reportPrivateUsage]
|
||||
mock_client.set_model.assert_not_called()
|
||||
mock_client.set_permission_mode.assert_not_called()
|
||||
|
||||
|
||||
# region Test ClaudeAgent Structured Output
|
||||
|
||||
|
||||
Reference in New Issue
Block a user