Skip to content

fix(llm): trust control escapes in JSON strings that escape backslashes#96737

Closed
ly-wang19 wants to merge 1 commit into
openclaw:mainfrom
ly-wang19:fix/repairjson-escaped-path-control-escape
Closed

fix(llm): trust control escapes in JSON strings that escape backslashes#96737
ly-wang19 wants to merge 1 commit into
openclaw:mainfrom
ly-wang19:fix/repairjson-escaped-path-control-escape

Conversation

@ly-wang19

Copy link
Copy Markdown
Contributor

What Problem This Solves

repairJson (src/llm/utils/json-parse.ts) — the tolerant parser behind parseStreamingJson/parseJsonWithRepair used for model tool-call arguments — corrupts genuine control escapes in strings that properly escape their backslashes.

repairJson has a deliberate, test-pinned heuristic: inside a JSON string, a backslash before a control-escape letter (b/f/n/r/t) whose decoded prefix looks like a Windows path is assumed to be an unescaped path separator and doubled. This repairs models that emit raw paths like {"path":"C:\new\file"} into the literal path (json-parse.test.ts:17-26).

The bug: the heuristic inspects only the decoded prefix, which is identical whether the wire used escaped \\ (valid) or raw \ (malformed). So for a string that escapes its path correctly — JSON.stringify({ text: "Saved to C:\\Users\\me\nDone." }), where the separators are \\ and the \n is a genuine newline — it still fired and rewrote the real newline into a literal \n:

JSON.parse text : "Saved to C:\\Users\\me\nDone."   (real newline)
repair    text  : "Saved to C:\\Users\\me\\nDone."  (literal backslash-n)  ← corrupted

This reaches production: the OpenAI / Azure / ChatGPT Responses transports call parseStreamingJson on the final, complete tool-call argument string with no JSON.parse-first guard (openai-responses-shared.ts:780,798; openai-transport-stream.ts:1550/1681/1785/2871/3221; openai-completions.ts; mistral.ts; extensions/amazon-bedrock/stream.runtime.ts). A model emitting a Bash/Write/Edit argument with a properly escaped Windows path plus a later newline (a two-line command, multi-line file content) runs a corrupted command. (The native Anthropic path is unaffected — it tries parseJsonObjectPreservingUnsafeIntegers first.)

Why This Change Was Made

A JSON.parse-first guard was rejected because {"path":"C:\new\file"} is valid JSON (\n→LF, \f→FF) yet the existing test deliberately wants the literal path — parse-first would regress it. The correct, minimal signal is the model's own escaping behaviour: once a string contains a properly escaped \\, the model demonstrably escapes backslashes, so a following \n/\t in that same string is a genuine control escape, not a raw path separator. A per-string sawEscapedBackslash flag (set on a real \\, reset at each string boundary) now also gates the path heuristic.

This is a coherent rule rather than a special-case: a string that escapes a backslash is trusted to escape its control chars. Strings with no escaped backslash — the pinned unescaped-path cases — are completely unchanged (the flag stays false, the heuristic still fires).

User Impact

Tool-call arguments from OpenAI-family models that escape a Windows path correctly and contain genuine newlines/tabs are no longer silently corrupted. Unescaped-path repair is unchanged. No config/API change.

Evidence

Real behavior proof

Behavior addressed: repairJson/parseStreamingJson rewrote a genuine control escape into a literal escape sequence when it followed a properly escaped Windows path, corrupting OpenAI-family tool-call arguments.

Real environment tested: Local checkout, Node v22.22.1, the real exported functions driven via tsx (including a side-by-side OLD origin/main vs NEW differential), plus the Vitest suite.

Exact steps or command run after this patch:

  • tsx repro vs JSON.parse: parseJsonWithRepair(JSON.stringify({ text: "Saved to C:\\Users\\me\nDone." })).
  • tsx OLD-vs-NEW differential over the reported case, all 5 pinned path tests, bare-drive inputs, and mixed-escaping inputs.
  • tsx fuzz: 20,000 random valid JSON objects whose Windows paths are properly escaped (mixed with real control chars) — assert parseJsonWithRepair === JSON.parse.
  • node scripts/run-vitest.mjs src/llm/utils/json-parse.test.ts
  • Fail-before: restored json-parse.ts from origin/main, kept the new test, re-ran it.

Evidence after fix (OLD = origin/main, NEW = patched):

CHANGED  reported bug (escaped path + real \n)
         OLD={"text":"...C:\\Users\\me\\nDone."}   NEW={"text":"...C:\\Users\\me\nDone."}=JSON.parse
same     bare drive C:\nfoo            OLD==NEW (heuristic's existing path-bet, unchanged)
same     pinned C:\bin\app.exe         OLD==NEW
same     pinned C:\new\file            OLD==NEW (literal path, contract preserved)
same     pinned C:\users\bob           OLD==NEW

escaped-path fuzz: 20000 cases, 0 mismatches vs JSON.parse
Test Files  1 passed (1) ; Tests  15 passed (15)

Observed result after fix: A genuine control escape after an escaped path round-trips to JSON.parse; all 5 unescaped-path repairs and every other pinned case are byte-identical to before; 20k escaped-path fuzz clean; oxfmt --check, oxlint, tsgo:core all green.

Fail-before (proves the test catches the regression):

 ❯ src/llm/utils/json-parse.test.ts:40:39
    40|     expect(parseJsonWithRepair(wire)).toEqual(JSON.parse(wire));
 Test Files  1 failed (1)

Scope / what was not changed (verified by the OLD-vs-NEW differential):

  • Bare drive letter with no escaping evidence (e.g. {"p":"C:\nfoo"}): OLD == NEW. This input is the same irreducibly-ambiguous case the heuristic already pins toward a path via json-parse.test.ts:20 (C:\new\file); it cannot be re-interpreted without breaking that shipped contract, so it is intentionally left as-is.
  • Mixed escaping inside one string (e.g. {"path":"C:\\Users\new"} — first separator escaped, second raw): the only inputs where NEW differs from OLD besides the fix itself. Here NEW follows the demonstrated escaping (matching JSON.parse) rather than OLD's literal-path guess. This requires self-inconsistent model output in a single string and is the deliberate consequence of the trust-the-escaping rule.
  • No live end-to-end run against a real OpenAI model (the corruption is in the pure parser upstream of execution, exercised directly above).

repairJson has a Windows-path heuristic: inside a string, a backslash before a
control-escape letter (b/f/n/r/t) whose decoded prefix looks like a Windows path
is treated as an unescaped path separator and doubled, so a model that emits a
raw path like {"path":"C:\new\file"} is repaired to the literal path instead of
a newline/formfeed. That bet is deliberate and test-pinned for unescaped paths.

But the heuristic inspected only the DECODED prefix, which is identical whether
the wire used escaped "\\" (valid) or raw "\" (malformed). So for a string that
properly escapes its path — e.g. JSON.stringify({text:"C:\\Users\\me\nDone."}),
where the path uses "\\" and the \n is a genuine newline — it still fired and
turned the real newline into a literal backslash-n. OpenAI/Azure/ChatGPT
Responses transports feed parseStreamingJson straight into tool calls with no
JSON.parse-first guard, so a model emitting a Bash/Write/Edit argument with a
properly escaped Windows path plus multi-line content ran a corrupted command.

Fix: once a string contains a properly escaped backslash ("\\"), the model
demonstrably escapes backslashes, so its later \n/\t/etc. are genuine control
escapes — skip the path heuristic for the rest of that string. Strings with no
escaped backslash (the pinned unescaped-path cases) repair exactly as before;
this only adds positive escaping evidence as a suppression signal. Add a
regression test asserting an escaped path followed by a real newline/tab
round-trips to JSON.parse.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@openclaw-barnacle openclaw-barnacle Bot added size: XS r: too-many-prs Auto-close: author has more than twenty active PRs. labels Jun 25, 2026
@openclaw-barnacle

Copy link
Copy Markdown

Closing this PR because the author has more than 20 active PRs in this repo. Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.

@ly-wang19

Copy link
Copy Markdown
Contributor Author

Reopening — I've trimmed my active-PR queue back under the repo limit. This fix is self-contained and verified (reproduce → fix → fail-before-test → green CI), with full proof in the PR body; no live-channel environment needed.

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

Labels

r: too-many-prs Auto-close: author has more than twenty active PRs. size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant