Skip to content

Commit d350fec

Browse files
committed
fix(ci): bound JSON nesting across Python versions
1 parent bc8d5f9 commit d350fec

2 files changed

Lines changed: 51 additions & 4 deletions

File tree

src/google/adk/plugins/bigquery_agent_analytics_plugin.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,12 @@ def _extract_tool_declarations(
445445
# values beyond it fail closed.
446446
_MAX_JSON_INSPECT_CHARS = 4_000_000
447447

448+
# Keep deeply nested JSON behavior deterministic across Python versions.
449+
# CPython <=3.13 rejects extreme nesting from json.loads with RecursionError,
450+
# while 3.14's iterative decoder accepts it. A fixed ceiling preserves the
451+
# fail-closed contract and bounds the materialized object graph everywhere.
452+
_MAX_JSON_NESTING_DEPTH = 1_000
453+
448454

449455
def _strip_bom_ws(value: str) -> str:
450456
"""Strips BOMs and ALL Unicode whitespace from the start of a string.
@@ -465,6 +471,36 @@ def _strip_bom_ws(value: str) -> str:
465471
return value[i:] if i else value
466472

467473

474+
def _json_nesting_exceeds_limit(value: str) -> bool:
475+
"""Returns whether JSON structural nesting exceeds the fixed ceiling.
476+
477+
Brackets and braces inside strings are payload, not structure. This linear
478+
preflight runs only after the existing character-size bound, before
479+
``json.loads`` can materialize a runtime-dependent deep object graph.
480+
"""
481+
depth = 0
482+
in_string = False
483+
escaped = False
484+
for char in value:
485+
if in_string:
486+
if escaped:
487+
escaped = False
488+
elif char == "\\":
489+
escaped = True
490+
elif char == '"':
491+
in_string = False
492+
continue
493+
if char == '"':
494+
in_string = True
495+
elif char in "[{":
496+
depth += 1
497+
if depth > _MAX_JSON_NESTING_DEPTH:
498+
return True
499+
elif char in "]}":
500+
depth -= 1
501+
return False
502+
503+
468504
def _normalize_json_native(
469505
obj: Any,
470506
max_len: int,
@@ -719,6 +755,9 @@ def _sanitize_json_blob(
719755
if len(stripped) > inspect_limit:
720756
return "[UNPARSEABLE_JSON_BLOB]", True, True
721757

758+
if _json_nesting_exceeds_limit(stripped):
759+
return "[UNPARSEABLE_JSON_BLOB]", True, True
760+
722761
# json.loads silently keeps only the LAST duplicate member, so a blob like
723762
# {"access_token":"SECRET","access_token":"x"} can compare equal after
724763
# sanitization while the raw string still carries the secret (#6360 review

tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10201,17 +10201,25 @@ def test_mapping_views_are_redacted(self):
1020110201
assert out["userdict"]["refresh_token"] == "[REDACTED]"
1020210202

1020310203
def test_deep_json_blob_fails_closed(self):
10204-
"""A blob too deep to parse becomes a sentinel, not a pass-through.
10204+
"""A blob beyond the fixed nesting limit fails closed on every runtime.
1020510205
10206-
json.loads raises RecursionError before the bounded traversal ever
10207-
runs; the row keeps flowing with the blob replaced (#6360 review
10208-
round 2 P2-5).
10206+
Older Python runtimes raise RecursionError while Python 3.14's iterative
10207+
JSON decoder accepts this input. The row must keep flowing with the same
10208+
whole-blob sentinel regardless (#6360 review round 2 P2-5).
1020910209
"""
1021010210
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
1021110211
deep = "[" * 10000 + "]" * 10000
1021210212
out, _ = truncate({"blob": deep}, 500 * 1024)
1021310213
assert out["blob"] == "[UNPARSEABLE_JSON_BLOB]"
1021410214

10215+
def test_json_nesting_limit_ignores_brackets_inside_strings(self):
10216+
"""Payload punctuation does not count as structural JSON nesting."""
10217+
truncate = bigquery_agent_analytics_plugin._recursive_smart_truncate
10218+
blob = json.dumps({"note": "prose " + "[" * 1001 + "]" * 1001})
10219+
out, truncated = truncate({"blob": blob}, 500 * 1024)
10220+
assert out["blob"] == blob
10221+
assert truncated is False
10222+
1021510223
@pytest.mark.asyncio
1021610224
async def test_shutdown_timeout_counts_lost_rows(self):
1021710225
"""Rows stranded by a shutdown timeout are counted, not silent.

0 commit comments

Comments
 (0)