Skip to content

Callable needs_approval fails open when tool arguments are invalid JSON #3863

Closed

Description

@baiyuxi930826

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_approval in src/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

  1. changed the title [-][Bug]: Callable needs_approval fails open when tool arguments are invalid JSON[/-] [+]Callable needs_approval fails open when tool arguments are invalid JSON[/+] on Jul 17, 2026
  2. added this to the 0.19.x milestone on Jul 17, 2026
  3. IgorGanapolsky commented on Jul 17, 2026

    @IgorGanapolsky

    Reliability note on fail-open approval (operator contract while the swallow-to-{} path exists):

    1. Invalid tool JSON is not “no risk”. Content-based needs_approval predicates that return False on {} create a false-success path: the run continues without HITL even though the model emitted unparsable / untrusted args.
    2. Prefer fail-closed for side effects. For any tool that can write money, CRM, email, or delete: if json.loads fails, treat as needs_approval=True (or hard-deny) rather than empty params. Bool needs_approval=True already avoids this class of bug for high-risk tools.
    3. Verifier outside the agent: log tool_name, raw arguments string, 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.”
    4. Non-object JSON (null / []) should not reach .get on 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.arguments that currently fails open is the gold artifact.

  4. baiyuxi930826 commented on Jul 17, 2026

    @baiyuxi930826
    Author

    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_approval fail closed on:

    1. JSONDecodeError (previously swallowed to {})
    2. non-object JSON (null / [] / scalars), so predicates never receive non-dicts for .get

    Bool needs_approval=True is unchanged. Includes a small unit regression suite (5 tests, all passing).

    PR: #3870

  5. DSHCorrectover commented on Aug 25, 2026

    @DSHCorrectover

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions