You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
asyncdefneeds_review(_ctx, params, _call_id) ->bool:
ifparams.get("amount", 0) >1000:
returnTrue# 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.
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.
Please read this first
needs_approvalfails 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 answeredFalse; here the predicate answers nothing at all.Describe the bug
When
needs_approvalis a callable, a return value that is not a bool is coerced withbool(), so a predicate that falls off the end of a branch returnsNone,bool(None)isFalse, and the guarded tool runs with no approval requested.src/agents/util/_approvals.py:The declared contract is a bool (
CustomToolApprovalFunction = Callable[..., MaybeAwaitable[bool]], and the shell/apply_patch variants likewise), soNoneis 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
mainat89c02c82, one script, five rows, four of them controls: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 returnTrueinfunction_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:
The annotation says
-> bool, so a type checker flags this one; a policy that returns insideif/elifchains without a finalelse, or that returns the result of a lookup likePOLICY.get(tool_name), is not flagged and behaves the same way.Debug information
mainat89c02c82)agents.testing.ScriptedModelmain; the coercion line is unchanged in 0.22.0.Repro steps
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_settingraisesUserErrorfor a non-bool setting understrict=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.