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 14:11:28 +00:00
committed by GitHub
co-authored by Copilot
parent 626b418622
commit c1cc6ee6df
6 changed files with 783 additions and 2 deletions
@@ -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