Repository navigation
Description
Please read this first
- Have you read the docs? Yes — Human-in-the-loop / tool approval docs
- Have you searched for related issues? Searched for needs_approval + invalid JSON / fail-open. Closest historical analog is closed MCP require_approval work (MCP require_approval invalid values can fail open #3168); this is a different surface (callable needs_approval on function tools).
Describe the bug
When needs_approval is a callable, invalid tool-call JSON is swallowed and replaced with {} before the predicate runs:
try:
parsed_args = json.loads(tool_call.arguments or "{}")
except json.JSONDecodeError:
parsed_args = {}Doc examples use content-based gates such as "refund" in params.get("subject", "").lower(). With params == {}, those predicates return False, so the run does not interrupt for approval.
Bool needs_approval=True is unaffected. Non-object JSON (null, []) can later crash the callable with UserError / AttributeError on .get.
The same swallow pattern exists in realtime (src/agents/realtime/session.py).
Debug information
- Agents SDK:
0.18.3@965335aba6f6c71500e0b8cdb4e9e495f5801d4d - Python 3.12 / Windows 11
- Surface:
function_needs_approvalinsrc/agents/run_internal/tool_execution.py
Repro steps
import asyncio
from agents import function_tool
from agents.run_context import RunContextWrapper
from agents.run_internal.tool_execution import function_needs_approval
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
async def needs_if_refund(_ctx, params, _call_id):
return "refund" in str(params.get("subject", "")).lower()
@function_tool(needs_approval=needs_if_refund)
async def send_email(subject: str, body: str) -> str:
return f"Sent {subject}"
async def main():
ctx = RunContextWrapper(context=None)
bad = ResponseFunctionToolCall(
type="function_call",
name="send_email",
call_id="call_bad",
arguments='{"subject": "REFUND request", "body": "please refund"', # invalid JSON
)
print(await function_needs_approval(send_email, ctx, bad))
# Observed: False (approval not required)
asyncio.run(main())Expected behavior
Invalid or non-object tool arguments should fail closed for callable approval gates (require approval, or raise ModelBehaviorError) rather than evaluating the predicate on {}.
Actual behavior
JSONDecodeError → {} → typical field-based predicates return False → no HITL interruption.
Suggested direction
On parse failure or non-dict result: treat as needs approval, or raise before calling the predicate. Align realtime path.
I'd be happy to prepare a PR with a regression test if this behavior is confirmed.
Activity
[-][Bug]: Callable needs_approval fails open when tool arguments are invalid JSON[/-][+]Callable needs_approval fails open when tool arguments are invalid JSON[/+]IgorGanapolsky commentedon Jul 17, 2026
Reliability note on fail-open approval (operator contract while the swallow-to-
{}path exists):needs_approvalpredicates that returnFalseon{}create a false-success path: the run continues without HITL even though the model emitted unparsable / untrusted args.json.loadsfails, treat asneeds_approval=True(or hard-deny) rather than empty params. Boolneeds_approval=Truealready avoids this class of bug for high-risk tools.tool_name, rawargumentsstring, parse_ok, approval_decision, and external write id. If parse_ok is false and the tool still ran, that is a production incident even when the run is “successful.”null/[]) should not reach.geton the predicate — same fail-closed gate.OP’s root cause in
function_needs_approval/ realtime session is clear. Until a library fix ships, production stacks should not use content-only callables as the sole gate for mutable tools.If useful for a regression test: redacted raw
tool_call.argumentsthat currently fails open is the gold artifact.baiyuxi930826 commentedon Jul 17, 2026
Thanks @IgorGanapolsky — agreed on the production reliability points (fail-closed for mutable tools, and not treating invalid JSON as empty-safe args).
Opened a fix that makes callable
needs_approvalfail closed on:JSONDecodeError(previously swallowed to{})null/[]/ scalars), so predicates never receive non-dicts for.getBool
needs_approval=Trueis unchanged. Includes a small unit regression suite (5 tests, all passing).PR: #3870
DSHCorrectover commentedon Aug 25, 2026
Building on @IgorGanapolsky's "verifier outside the agent" point — the structural fix is to move validation out of any in-process callback entirely.
We hit the same class of bug from a different angle: a permission gate that was conditional on the presence of a logging object. When the logging object was absent (a different dispatch path), authorization was silently skipped. The pattern repeats across frameworks because in-process hooks proliferate with dispatch paths, and every new path is a chance to forget the check.
A boundary verifier that all dispatch paths must traverse — sitting as a sidecar between the agent runtime and the tool transport — eliminates this by construction. If parse_ok is false, the call is denied before it reaches the tool; there is no code path that bypasses it because the network boundary is the single choke point.
We implemented this as an open-source library: every tool call is JCS-canonicalized → SHA-256 action digest → Ed25519-signed receipt with 22 fields (tool name, arguments, caller identity, authorization scope, integrity hash, schema/structure/latency/cost/security dimensions, timestamp, previous receipt hash for chain integrity). Python e2e P50 is ~27μs including signing. The verify step is fail-closed: any absent field, broken chain link, or parse failure returns DENY.
pip install ccs-verifier— conformance vectors cover malformed JSON, schema violations, identity mismatches, and replay. The point isn't that frameworks should stop shipping guardrails — it's that guardrails inside the process are defense-in-depth, and the cryptographic admission boundary is the control that auditors can independently verify without trusting the runtime.