Skip to content

Callable needs_approval fails open when the predicate returns a non-bool (e.g. an unhandled branch returning None) #4845

Description

@mahirhir

Please read this first

  • Have you read the docs? Yes, the human-in-the-loop / tool approval page.
  • Have you searched for related issues? Yes. The closest is Callable needs_approval fails open when tool arguments are invalid JSON #3863, "Callable needs_approval fails open when tool arguments are invalid JSON", which is closed and fixed. This is the remaining sibling on the same function: there the predicate was fed {} and answered False; here the predicate answers nothing at all.

Describe the bug

When needs_approval is a callable, a return value that is not a bool is coerced with bool(), so a predicate that falls off the end of a branch returns None, bool(None) is False, and the guarded tool runs with no approval requested.

src/agents/util/_approvals.py:

if callable(needs_approval_setting):
    maybe_result = needs_approval_setting(*args)
    if inspect.isawaitable(maybe_result):
        maybe_result = await maybe_result
    return bool(maybe_result)

The declared contract is a bool (CustomToolApprovalFunction = Callable[..., MaybeAwaitable[bool]], and the shell/apply_patch variants likewise), so None is out of contract. The point is what happens to an out-of-contract answer on a security gate: everywhere else in this feature an unanswerable approval question fails closed, and here it fails open.

Measured on main at 89c02c82, one script, five rows, four of them controls:

needs_approval policy -> did the guarded tool run without approval?
  ran=no   returns True   (control: must ask)             -> asked for approval
  ran=YES  returns False  (control: must run)             -> no approval requested
  ran=no   raises         (unanswerable: fails closed)    -> UserError: Error running tool wire_money: policy backend unreac
  ran=no   unparsable args (unanswerable: fails closed)   -> asked for approval
  ran=YES  returns None   (unhandled branch)              -> no approval requested

Rows one and two show the harness can tell an approval request from an execution. Rows three and four are the two unanswerable cases that already fail closed: a predicate that raises is caught in tool_planning (except Exception: needs_approval = True), and unparsable arguments return True in function_needs_approval (the fix from #3863). Row five is the same kind of non-answer taking the other branch.

The predicate style in the docs is the one this bites. Both documented examples are single-expression returns, but the moment a policy grows a branch:

async def needs_review(_ctx, params, _call_id) -> bool:
    if params.get("amount", 0) > 1000:
        return True
    # every other amount falls through -> None -> tool runs unapproved

The annotation says -> bool, so a type checker flags this one; a policy that returns inside if/elif chains without a final else, or that returns the result of a lookup like POLICY.get(tool_name), is not flagged and behaves the same way.

Debug information

  • Agents SDK version: 0.22.0 (main at 89c02c82)
  • Python version: 3.14.4
  • Operating system: Windows
  • Model and model provider: none, reproduced with agents.testing.ScriptedModel
  • Does the issue reproduce with the latest Agents SDK release? Reproduced on main; the coercion line is unchanged in 0.22.0.
  • Does the issue occur consistently or intermittently? Consistently.

Repro steps

import asyncio

from openai.types.responses import (
    ResponseFunctionToolCall,
    ResponseOutputMessage,
    ResponseOutputText,
)

from agents import Agent, Runner, function_tool
from agents.testing import ScriptedModel

EXECUTED: list[str] = []


def tool_call(arguments: str = '{"amount": 100}') -> ResponseFunctionToolCall:
    return ResponseFunctionToolCall(
        id="fc_1", call_id="call_1", type="function_call", name="wire_money", arguments=arguments
    )


def final_message() -> ResponseOutputMessage:
    return ResponseOutputMessage(
        id="msg_1",
        type="message",
        role="assistant",
        content=[ResponseOutputText(text="done", type="output_text", annotations=[], logprobs=[])],
        status="completed",
    )


def build(policy, arguments: str):
    @function_tool(needs_approval=policy)
    def wire_money(amount: int) -> str:
        EXECUTED.append(f"wired {amount}")
        return "wired"

    return Agent(
        name="banker",
        model=ScriptedModel(steps=[[tool_call(arguments)], [final_message()]]),
        tools=[wire_money],
    )


async def probe(label: str, policy, arguments: str = '{"amount": 100}') -> None:
    EXECUTED.clear()
    result = await Runner.run(build(policy, arguments), "send it")
    asked = bool(getattr(result, "interruptions", []))
    print(f'  ran={"YES" if EXECUTED else "no "}  {label:44} -> {"asked for approval" if asked else "no approval requested"}')


def always_true(ctx, args, call_id):
    return True


def always_false(ctx, args, call_id):
    return False


def forgot_a_branch(ctx, args, call_id):
    if args.get("amount", 0) > 1000:
        return True
    # no return for anything else -> None


async def main() -> None:
    await probe("returns True  (control: must ask)", always_true)
    await probe("returns False (control: must run)", always_false)
    await probe("unparsable args (control: fails closed)", always_false, "not json")
    await probe("returns None  (unhandled branch)", forgot_a_branch)


asyncio.run(main())

Expected behavior

An approval predicate that does not return a bool has not answered the question, so it should be treated the way the other unanswerable cases already are: require approval, or raise.

I have not opened a PR because the choice is yours and the helper already carries the dial for it. evaluate_needs_approval_setting raises UserError for a non-bool setting under strict=True; applying the same rule to a non-bool result would be consistent, but it turns a currently-silent truthy return (a policy returning a non-empty reason string to mean yes) into an error. Failing closed on a non-bool result instead keeps those callers working and only changes the falsy ones, which are the dangerous half. Happy to send whichever you prefer, with tests.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions