From 265264eb5fa605089dc78fa0cf07393075a4c2b1 Mon Sep 17 00:00:00 2001 From: ayaangazali Date: Thu, 3 Sep 2026 18:04:50 -0700 Subject: [PATCH] fix(approvals): fail closed when a callable needs_approval returns a non-bool evaluate_needs_approval_setting coerced the predicate's answer with bool(), so a callable that fell through a branch and returned None was read as False and the guarded tool ran with no approval requested. The declared contract is Callable[..., MaybeAwaitable[bool]], so a non-bool answer means the predicate did not answer the approval question at all. Treat that the same way #3867 treats arguments the predicate cannot inspect, by requiring approval rather than skipping it. Genuine True and False answers are unchanged. --- src/agents/util/_approvals.py | 8 +++- tests/test_hitl_error_scenarios.py | 77 ++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/agents/util/_approvals.py b/src/agents/util/_approvals.py index 8992f5ada2..8cda40c76a 100644 --- a/src/agents/util/_approvals.py +++ b/src/agents/util/_approvals.py @@ -42,7 +42,13 @@ async def evaluate_needs_approval_setting( maybe_result = needs_approval_setting(*args) if inspect.isawaitable(maybe_result): maybe_result = await maybe_result - return bool(maybe_result) + if not isinstance(maybe_result, bool): + # The predicate is declared to return a bool, so anything else means it did not + # answer the approval question. A branch that falls through returns None, and + # coercing that to False would run a guarded tool with no approval. Fail closed, + # matching the unparseable-arguments path in #3867. + return True + return maybe_result if strict: raise UserError( f"Invalid needs_approval value: expected a bool or callable, " diff --git a/tests/test_hitl_error_scenarios.py b/tests/test_hitl_error_scenarios.py index eb59cb1ce1..26d28a59f8 100644 --- a/tests/test_hitl_error_scenarios.py +++ b/tests/test_hitl_error_scenarios.py @@ -962,6 +962,83 @@ async def invoke_tool(_ctx: Any, raw_arguments: str) -> str: assert tool_inputs == [] +@pytest.mark.parametrize( + "verdict", [None, "", 0, [], {}], ids=["none", "empty-str", "zero", "empty-list", "empty-dict"] +) +@pytest.mark.asyncio +async def test_callable_function_approval_fails_closed_for_non_bool_verdict( + verdict: Any, +) -> None: + """A predicate that does not answer with a bool must not let a guarded tool run. + + The declared return type is bool, so a branch that falls through and returns None is out + of contract. Coercing it with bool() would silently mean "no approval needed", which is + the same unanswerable-question shape that #3867 made fail closed. + """ + tool_inputs: list[str] = [] + + async def needs_approval(_ctx: Any, _params: dict[str, Any], _call_id: str) -> Any: + return verdict + + async def invoke_tool(_ctx: Any, raw_arguments: str) -> str: + tool_inputs.append(raw_arguments) + return "sent" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=needs_approval, + ) + model, agent = make_model_and_agent(tools=[tool]) + model.enqueue([make_function_tool_call(tool.name, arguments="{}", call_id="call-non-bool")]) + + result = await Runner.run(agent, "send an email") + + assert len(result.interruptions) == 1 + assert result.interruptions[0].tool_name == tool.name + assert tool_inputs == [] + + +@pytest.mark.parametrize("verdict", [True, False], ids=["true", "false"]) +@pytest.mark.asyncio +async def test_callable_function_approval_honors_real_bool_verdicts(verdict: bool) -> None: + """Genuine bool answers keep their existing meaning.""" + tool_inputs: list[str] = [] + + async def needs_approval(_ctx: Any, _params: dict[str, Any], _call_id: str) -> bool: + return verdict + + async def invoke_tool(_ctx: Any, raw_arguments: str) -> str: + tool_inputs.append(raw_arguments) + return "sent" + + tool = FunctionTool( + name="send_email", + description="Send an email.", + params_json_schema={"type": "object", "properties": {}}, + on_invoke_tool=invoke_tool, + needs_approval=needs_approval, + ) + model, agent = make_model_and_agent(tools=[tool]) + model.extend( + [ + [make_function_tool_call(tool.name, arguments="{}", call_id="call-bool")], + [get_text_message("done")], + ] + ) + + result = await Runner.run(agent, "send an email") + + if verdict: + assert len(result.interruptions) == 1 + assert tool_inputs == [] + else: + assert result.interruptions == [] + assert tool_inputs == ["{}"] + + @pytest.mark.asyncio async def test_callable_function_approval_receives_valid_object_arguments() -> None: """Valid object arguments should preserve callable approval behavior."""