From b564ad41253d445f0f0754baaf2f88bb6acb7026 Mon Sep 17 00:00:00 2001 From: ITSGJY <133723203+ITSGJY@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:56:20 +0800 Subject: [PATCH] fix(agent-loop): enforce portable JSON parse limits Bound nested JSON structures and integer tokens explicitly for offline case manifests and driver receipts, preserving deterministic evidence validation across Python runtimes. --- scripts/agent_loop_benchmark.py | 4 ++- scripts/agent_loop_report.py | 7 ++++- scripts/host_timing_dogfood.py | 7 ++++- scripts/json_bounds.py | 42 +++++++++++++++++++++++++ scripts/tests/test_agent_loop_report.py | 18 +++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 scripts/json_bounds.py diff --git a/scripts/agent_loop_benchmark.py b/scripts/agent_loop_benchmark.py index f0bef56fc..ce75a995c 100644 --- a/scripts/agent_loop_benchmark.py +++ b/scripts/agent_loop_benchmark.py @@ -28,12 +28,14 @@ try: from scripts import agent_loop_report as report + from scripts import json_bounds from scripts import agent_temporal_evidence as temporal from scripts.e2e_generic_agent_ws import GenericFixture, GENERIC_CASES, pressure_script, mixed_note_script, encoded_csv_script from scripts.agent_knowledge_fixture import KnowledgeFixture, CASE_IDS as KNOWLEDGE_CASES from scripts import agent_knowledge_evidence as knowledge except ModuleNotFoundError: import agent_loop_report as report + import json_bounds import agent_temporal_evidence as temporal from e2e_generic_agent_ws import GenericFixture, GENERIC_CASES, pressure_script, mixed_note_script, encoded_csv_script from agent_knowledge_fixture import KnowledgeFixture, CASE_IDS as KNOWLEDGE_CASES @@ -439,7 +441,7 @@ def _load_driver_receipt(path: Path) -> dict[str, Any]: except UnicodeDecodeError as exc: raise BenchmarkError("driver result must be UTF-8 JSON") from exc try: - value = json.loads(text) + value = json_bounds.loads(text) except json.JSONDecodeError as exc: raise BenchmarkError(f"driver result is not valid JSON: {exc}") from exc except (RecursionError, ValueError) as exc: diff --git a/scripts/agent_loop_report.py b/scripts/agent_loop_report.py index 055383542..e93f8b4e0 100644 --- a/scripts/agent_loop_report.py +++ b/scripts/agent_loop_report.py @@ -20,6 +20,11 @@ from pathlib import Path from typing import Any, Iterable +try: + from scripts import json_bounds +except ModuleNotFoundError: + import json_bounds + SCHEMA_VERSION = 1 DEFAULT_CASE_MANIFEST = Path(__file__).with_name("agent_loop_cases.json") CODE_MODE_TOOLS = frozenset(("execute_code_mode", "execute_effectful_code_mode", "execute_mutating_code_mode")) @@ -177,7 +182,7 @@ def load_case_manifest(path: Path) -> dict[str, Any]: except UnicodeDecodeError as exc: raise ReportError(f"case manifest must be UTF-8: {path}") from exc try: - value = json.loads(text) + value = json_bounds.loads(text) except json.JSONDecodeError as exc: raise ReportError(f"case manifest is not valid JSON: {path}:{exc.lineno}") from exc except (RecursionError, ValueError) as exc: diff --git a/scripts/host_timing_dogfood.py b/scripts/host_timing_dogfood.py index bd49580ff..fca10f723 100644 --- a/scripts/host_timing_dogfood.py +++ b/scripts/host_timing_dogfood.py @@ -18,6 +18,11 @@ import sqlite3 import sys +try: + from scripts import json_bounds +except ModuleNotFoundError: + import json_bounds + MAX_ROWS = 100_000 # Read only bounded diagnostic projections from potentially larger audit rows. # Oversized summaries remain selected events but cannot provide canonical proof. @@ -73,7 +78,7 @@ def _read_bounded_json(path: Path) -> object: if len(raw) > MAX_INPUT_JSON_BYTES: raise ReportError("input JSON exceeds bounded size") try: - return json.loads(raw.decode("utf-8")) + return json_bounds.loads(raw.decode("utf-8")) except (UnicodeError, ValueError, RecursionError) as exc: # Deeply nested JSON can exhaust the parser stack below the byte cap. raise ReportError("invalid input JSON") from exc diff --git a/scripts/json_bounds.py b/scripts/json_bounds.py new file mode 100644 index 000000000..29acc853f --- /dev/null +++ b/scripts/json_bounds.py @@ -0,0 +1,42 @@ +"""Explicit JSON parsing limits shared by offline Python tooling.""" + +from __future__ import annotations + +import json +from typing import Any + +MAX_NESTING = 256 +MAX_INTEGER_DIGITS = 4300 + + +def _bounded_integer(token: str) -> int: + if len(token.removeprefix("-")) > MAX_INTEGER_DIGITS: + raise ValueError("JSON integer exceeds bounded digits") + return int(token) + + +def loads(text: str) -> Any: + # Interpreter recursion guards vary. Bound JSON depth before allocating + # the decoded tree, ignoring punctuation inside escaped JSON strings. + depth = 0 + in_string = False + escaped = False + for char in text: + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + elif char == '"': + in_string = True + elif char in "[{": + depth += 1 + if depth > MAX_NESTING: + raise ValueError("JSON nesting exceeds bounded depth") + elif char in "]}": + depth -= 1 + + # Structural and Unicode validation remain with Python's JSON decoder. + return json.loads(text, parse_int=_bounded_integer) diff --git a/scripts/tests/test_agent_loop_report.py b/scripts/tests/test_agent_loop_report.py index 541b79b43..09f78d021 100644 --- a/scripts/tests/test_agent_loop_report.py +++ b/scripts/tests/test_agent_loop_report.py @@ -9,6 +9,7 @@ from pathlib import Path from scripts import agent_loop_report as report +from scripts import json_bounds def code_mode_composition(**overrides: object) -> dict[str, object]: @@ -1395,6 +1396,23 @@ def test_manifest_loader_fail_closes_on_pathological_json(self) -> None: with self.assertRaisesRegex(report.ReportError, "bounded JSON"): report.load_case_manifest(huge_int) + def test_bounded_json_recognizes_structure_outside_quoted_strings(self) -> None: + literal = '"}[]{' * 100 + expected = {"literal": literal, "data": {"ok": True}} + self.assertEqual(json_bounds.loads(json.dumps(expected)), expected) + with self.assertRaises(json.JSONDecodeError): + json_bounds.loads('{"value":]}') + + def test_bounded_json_enforces_explicit_depth_and_integer_limits(self) -> None: + depth = json_bounds.MAX_NESTING + self.assertIsInstance( + json_bounds.loads("[" * depth + "0" + "]" * depth), list + ) + with self.assertRaisesRegex(ValueError, "bounded depth"): + json_bounds.loads("[" * (depth + 1) + "0" + "]" * (depth + 1)) + with self.assertRaisesRegex(ValueError, "bounded digits"): + json_bounds.loads("1" * (json_bounds.MAX_INTEGER_DIGITS + 1)) + def test_manifest_requires_fields_and_stable_serialization(self) -> None: manifest = report.load_case_manifest(report.DEFAULT_CASE_MANIFEST) serialized_once = report._stable_json(manifest)