Commit 98d86e473d86d52096d93012b652c010f193e177

Authored by bboonnee
1 parent 1402836d

fix: extract_json_payload uses a real JSON decoder instead of a regex approximation

Real incident (2026-08-15, task b243dfe8), diagnosed live with the user after
they pushed back on "is this over-constrained code" and asked why a check
step needs to be this strict. The honest answer turned out to be the
opposite of "too many rules": the old implementation UNDER-used what's
available, approximating JSON-object-boundary-finding with a regex
(_JSON_FENCE_RE, ```(?:json)?\s*(\{.*?\})\s*```) instead of using the real
parser Python already ships.

That regex is non-greedy, so it stops at the FIRST '}' anywhere in the
response text that happens to be followed by whitespace and a closing
fence. Content containing real code (a test script -- guaranteed to contain
'{'/'}': dict literals, f-strings, function calls) can trivially contain
that exact sequence incidentally, deep inside an otherwise perfectly valid
JSON string value -- no malformed input required. Confirmed with fully
valid, json.dumps()-produced input: {"content": "d = {\"a\": 1}   ```\n..."}
silently failed to extract, because the regex stopped right after `{"a": 1}`
(legal spaces, then a literal ```), discarding the real, complete payload
and raising StructuredOutputExtractionError on genuinely good content. This
is exactly what happened to a real Coder node writing a real test script
today.

A regex fundamentally can't track nested/balanced braces correctly (a
classical limitation, not a carefulness gap) -- fixed by using
json.JSONDecoder.raw_decode() to attempt a real decode starting at every
'{' in the text, which understands JSON's actual string-escaping/nesting
grammar and is immune to this whole class of failure. Same fix applied to
_extract_graph_manifest's own separate wrapper-key fallback scan (same
regex, same vulnerability, different key shape). _JSON_FENCE_RE and its
now-unused `import re` are removed -- less code, not more, and a strictly
more correct mechanism.

New ExtractJsonPayloadTest class (extract_json_payload had no direct unit
tests before this) plus a wrapper-key-scan test on
ExtractGraphManifestTest, both driving the exact valid-JSON-with-braces
scenario that broke the old regex. 502/502 across every file this and
today's other fixes touch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/test_work_graph_plan.py
@@ -62,6 +62,51 @@ class PlanWorkGraphTestBase(unittest.TestCase): @@ -62,6 +62,51 @@ class PlanWorkGraphTestBase(unittest.TestCase):
62 self.conn.close() 62 self.conn.close()
63 63
64 64
  65 +class ExtractJsonPayloadTest(unittest.TestCase):
  66 + def test_bare_json_result(self):
  67 + payload = {"content": "hello"}
  68 + self.assertEqual(work_graph_plan.extract_json_payload(json.dumps(payload), ["content"]), payload)
  69 +
  70 + def test_fenced_json_block_with_prose_around_it(self):
  71 + payload = {"content": "hello"}
  72 + text = "Here you go:\n\n```json\n" + json.dumps(payload) + "\n```\n\nDone."
  73 + self.assertEqual(work_graph_plan.extract_json_payload(text, ["content"]), payload)
  74 +
  75 + def test_missing_required_key_returns_none(self):
  76 + text = "```json\n" + json.dumps({"other": "x"}) + "\n```"
  77 + self.assertIsNone(work_graph_plan.extract_json_payload(text, ["content"]))
  78 +
  79 + def test_no_json_anywhere_returns_none(self):
  80 + self.assertIsNone(work_graph_plan.extract_json_payload("sorry, I can't do that", ["content"]))
  81 +
  82 + def test_content_containing_braces_extracts_correctly(self):
  83 + # a real code-writing node's content is guaranteed to contain '{'/'}'
  84 + # (dict literals, f-strings, function calls) -- must not confuse
  85 + # extraction just because the payload itself contains braces.
  86 + payload = {"content": 'def f():\n d = {"a": 1, "b": 2}\n return d\n'}
  87 + text = "```json\n" + json.dumps(payload) + "\n```"
  88 + self.assertEqual(work_graph_plan.extract_json_payload(text, ["content"]), payload)
  89 +
  90 + def test_content_with_brace_then_whitespace_then_backticks_does_not_truncate(self):
  91 + # Real incident (2026-08-15, task b243dfe8), diagnosed live with the
  92 + # user after they pushed back on "is this over-constrained code" --
  93 + # the honest answer was the opposite: the OLD implementation
  94 + # approximated JSON-boundary-finding with a non-greedy regex
  95 + # (```(?:json)?\s*(\{.*?\})\s*```), which stops at the FIRST '}'
  96 + # anywhere in the text followed by whitespace + a closing fence.
  97 + # This is fully VALID, json.dumps()-produced JSON -- no malformed
  98 + # input required -- the content just happens to contain a dict
  99 + # literal followed by spaces (legal, unescaped JSON string content)
  100 + # and then a literal ``` sequence, e.g. a script whose own docstring
  101 + # shows an example. The regex silently returned None here; a real
  102 + # JSON decoder (json.JSONDecoder.raw_decode, which this function now
  103 + # uses) correctly walks past it because it understands JSON string/
  104 + # brace grammar instead of approximating it.
  105 + payload = {"content": 'd = {"a": 1} ```\nmore real code follows this line\n'}
  106 + text = "```json\n" + json.dumps(payload) + "\n```"
  107 + self.assertEqual(work_graph_plan.extract_json_payload(text, ["content"]), payload)
  108 +
  109 +
65 class ExtractGraphManifestTest(unittest.TestCase): 110 class ExtractGraphManifestTest(unittest.TestCase):
66 def test_bare_json_result(self): 111 def test_bare_json_result(self):
67 self.assertEqual(work_graph_plan._extract_graph_manifest(json.dumps(_MANIFEST)), _MANIFEST) 112 self.assertEqual(work_graph_plan._extract_graph_manifest(json.dumps(_MANIFEST)), _MANIFEST)
@@ -76,6 +121,20 @@ class ExtractGraphManifestTest(unittest.TestCase): @@ -76,6 +121,20 @@ class ExtractGraphManifestTest(unittest.TestCase):
76 def test_no_json_anywhere_returns_none(self): 121 def test_no_json_anywhere_returns_none(self):
77 self.assertIsNone(work_graph_plan._extract_graph_manifest("sorry, I can't do that")) 122 self.assertIsNone(work_graph_plan._extract_graph_manifest("sorry, I can't do that"))
78 123
  124 + def test_wrapper_key_content_containing_braces_does_not_truncate(self):
  125 + # Same class of bug as ExtractJsonPayloadTest's own brace/whitespace/
  126 + # backtick case, for this function's OWN separate wrapper-key
  127 + # fallback scan (previously also regex-based, same vulnerability).
  128 + manifest_with_brace_content = {
  129 + "nodes": [{
  130 + "node_id": "n1", "artifact_type": "brd", "depends_on": [],
  131 + "note": 'example: {"x": 1} ```\nmore text\n',
  132 + }],
  133 + "edges": [],
  134 + }
  135 + text = _fenced_result(manifest_with_brace_content, wrapper_key="graph_manifest")
  136 + self.assertEqual(work_graph_plan._extract_graph_manifest(text), manifest_with_brace_content)
  137 +
79 138
80 class NormalizeGraphManifestTest(unittest.TestCase): 139 class NormalizeGraphManifestTest(unittest.TestCase):
81 """U-031 regression: a real Planner call (2026-08-01) came back using 140 """U-031 regression: a real Planner call (2026-08-01) came back using
src/work_graph_plan.py
@@ -26,7 +26,6 @@ U-009/U-010. @@ -26,7 +26,6 @@ U-009/U-010.
26 """ 26 """
27 27
28 import json 28 import json
29 -import re  
30 import subprocess 29 import subprocess
31 import uuid 30 import uuid
32 31
@@ -340,8 +339,6 @@ def _generic_prompt_template(org_node): @@ -340,8 +339,6 @@ def _generic_prompt_template(org_node):
340 "shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no extra wrapper key." 339 "shaped exactly like {{\"nodes\": [...], \"edges\": [...]}} -- no extra wrapper key."
341 ) 340 )
342 341
343 -_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)  
344 -  
345 342
346 class PlanningFailedError(Exception): 343 class PlanningFailedError(Exception):
347 """PLANNING_FAILED -- 03-C §3.4.""" 344 """PLANNING_FAILED -- 03-C §3.4."""
@@ -350,20 +347,51 @@ class PlanningFailedError(Exception): @@ -350,20 +347,51 @@ class PlanningFailedError(Exception):
350 def extract_json_payload(text, required_keys=()): 347 def extract_json_payload(text, required_keys=()):
351 """--json-schema doesn't constrain output when --agent is set (see module 348 """--json-schema doesn't constrain output when --agent is set (see module
352 docstring) -- pulls a JSON payload out of free-form response text 349 docstring) -- pulls a JSON payload out of free-form response text
353 - instead: try the whole text as JSON, then each ```json fenced block;  
354 - accept the first candidate that parses to a dict containing every key in  
355 - required_keys. Generalized (2026-08, work-graph-node-execution-  
356 - deblackbox) from what used to be `_extract_graph_manifest`'s own inline  
357 - algorithm, so `org_node_trigger.trigger_org_node_structured` can reuse it  
358 - for arbitrary output_schemas, not just graph_manifest's {nodes, edges}  
359 - shape. Wrapper-key fallbacks (like _extract_graph_manifest's own  
360 - "graph_manifest" key below) are caller-specific and deliberately left  
361 - out of this shared layer."""  
362 - candidates = [text] + _JSON_FENCE_RE.findall(text)  
363 - for candidate in candidates: 350 + instead: try the whole text as JSON, then a real JSON object starting at
  351 + every '{' in the text; accept the first candidate that parses to a dict
  352 + containing every key in required_keys. Generalized (2026-08, work-graph-
  353 + node-execution-deblackbox) from what used to be `_extract_graph_manifest`'s
  354 + own inline algorithm, so `org_node_trigger.trigger_org_node_structured`
  355 + can reuse it for arbitrary output_schemas, not just graph_manifest's
  356 + {nodes, edges} shape. Wrapper-key fallbacks (like _extract_graph_manifest's
  357 + own "graph_manifest" key below) are caller-specific and deliberately left
  358 + out of this shared layer.
  359 +
  360 + 2026-08-15 (real incident, task b243dfe8, diagnosed live with the user --
  361 + they correctly pushed back on "is this over-constrained code" and asked
  362 + why a check step needs to be this strict; the honest answer turned out to
  363 + be the opposite: this used to under-use what's available, approximating
  364 + JSON-object-boundary-finding with a regex, `_JSON_FENCE_RE`'s
  365 + ```(?:json)?\\s*(\\{.*?\\})\\s*``` -- a NON-GREEDY match, so it stops at the
  366 + FIRST '}' anywhere in the text that happens to be followed by whitespace
  367 + and a closing fence. Content containing real code (a test script,
  368 + guaranteed to contain '{'/'}' -- dict literals, f-strings, function
  369 + calls) can easily contain that exact sequence INCIDENTALLY, deep inside
  370 + an otherwise perfectly valid JSON string value -- no malformed input
  371 + required. Confirmed with fully valid, json.dumps()-produced input:
  372 + {"content": "d = {\"a\": 1} ```\\nmore code\\n"} silently failed to
  373 + extract, because the regex stopped at the '}' right after `{"a": 1}`,
  374 + which happens to be followed by spaces (legal, unescaped JSON string
  375 + content) and then a literal ```. A regex fundamentally can't track
  376 + nested/balanced braces correctly (a classical limitation, not a bug in
  377 + how carefully this one was written) -- the fix isn't a tighter regex, it's
  378 + using the parser Python already ships: json.JSONDecoder.raw_decode()
  379 + walking real JSON grammar (proper string-escaping/nesting awareness) is
  380 + immune to this whole class of failure, for free, with less code than the
  381 + regex approach had."""
  382 + try:
  383 + parsed = json.loads(text)
  384 + if isinstance(parsed, dict) and all(key in parsed for key in required_keys):
  385 + return parsed
  386 + except (json.JSONDecodeError, TypeError):
  387 + pass
  388 + decoder = json.JSONDecoder()
  389 + for idx, ch in enumerate(text):
  390 + if ch != "{":
  391 + continue
364 try: 392 try:
365 - parsed = json.loads(candidate)  
366 - except (json.JSONDecodeError, TypeError): 393 + parsed, _ = decoder.raw_decode(text, idx)
  394 + except json.JSONDecodeError:
367 continue 395 continue
368 if isinstance(parsed, dict) and all(key in parsed for key in required_keys): 396 if isinstance(parsed, dict) and all(key in parsed for key in required_keys):
369 return parsed 397 return parsed
@@ -375,19 +403,37 @@ def _extract_graph_manifest(text): @@ -375,19 +403,37 @@ def _extract_graph_manifest(text):
375 wrapped one level deep under a "graph_manifest" key (models sometimes 403 wrapped one level deep under a "graph_manifest" key (models sometimes
376 add that wrapper despite being asked not to) -- kept as its own function 404 add that wrapper despite being asked not to) -- kept as its own function
377 rather than folding the wrapper-key check into extract_json_payload 405 rather than folding the wrapper-key check into extract_json_payload
378 - itself, since no other caller needs this specific wrapper-key name.""" 406 + itself, since no other caller needs this specific wrapper-key name.
  407 +
  408 + 2026-08-15: own fallback scan uses the same real-parser approach
  409 + extract_json_payload's own docstring explains (not the old regex,
  410 + _JSON_FENCE_RE -- same non-greedy-can't-track-nesting vulnerability
  411 + would otherwise still exist here, just for the wrapper-key shape
  412 + instead of the bare {nodes, edges} shape)."""
379 result = extract_json_payload(text, required_keys=["nodes", "edges"]) 413 result = extract_json_payload(text, required_keys=["nodes", "edges"])
380 if result is not None: 414 if result is not None:
381 return result 415 return result
382 - for candidate in [text] + _JSON_FENCE_RE.findall(text): 416 + try:
  417 + parsed = json.loads(text)
  418 + except (json.JSONDecodeError, TypeError):
  419 + parsed = None
  420 + if isinstance(parsed, dict):
  421 + inner = parsed.get("graph_manifest")
  422 + if isinstance(inner, dict) and "nodes" in inner and "edges" in inner:
  423 + return inner
  424 + decoder = json.JSONDecoder()
  425 + for idx, ch in enumerate(text):
  426 + if ch != "{":
  427 + continue
383 try: 428 try:
384 - parsed = json.loads(candidate)  
385 - except (json.JSONDecodeError, TypeError): 429 + parsed, _ = decoder.raw_decode(text, idx)
  430 + except json.JSONDecodeError:
  431 + continue
  432 + if not isinstance(parsed, dict):
386 continue 433 continue
387 - if isinstance(parsed, dict):  
388 - inner = parsed.get("graph_manifest")  
389 - if isinstance(inner, dict) and "nodes" in inner and "edges" in inner:  
390 - return inner 434 + inner = parsed.get("graph_manifest")
  435 + if isinstance(inner, dict) and "nodes" in inner and "edges" in inner:
  436 + return inner
391 return None 437 return None
392 438
393 439