Skip to content

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

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#96748
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).

@clawsweeper

clawsweeper Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed June 28, 2026, 1:25 AM ET / 05:25 UTC.

Summary
The branch adds per-string escaped-backslash tracking in repairJson plus regression tests so valid escaped Windows paths preserve later JSON newline and tab control escapes.

PR surface: Source +16, Tests +14. Total +30 across 2 files.

Reproducibility: yes. I did not execute the repro in this read-only review, but current main and v2026.6.10 source still apply the Windows-path heuristic without escaped-backslash evidence, and the PR body includes fail-before/pass-after terminal proof against the exported parser functions.

Review metrics: 1 noteworthy metric.

  • Exported parser behavior: 1 shared parser rule changed. parseStreamingJson is used by provider transports and exported through the plugin SDK, so maintainers should notice the compatibility tradeoff before merge.

Stored data model
Persistent data-model change detected: serialized state: src/llm/utils/json-parse.test.ts. Confirm migration or upgrade compatibility proof before merge.

Root-cause cluster
Relationship: canonical
Canonical: #96748
Summary: This PR is the active candidate for escaped-path control-escape corruption; related parser items are historical, adjacent, or closed duplicate submissions rather than replacements.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🦞 diamond lobster
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • [P2] Get parser/runtime owner acknowledgement of the mixed-escaping tradeoff before merge.

Risk before merge

  • [P1] The new rule intentionally trusts JSON control escapes for the rest of a string after one escaped backslash, so self-inconsistent malformed strings can change from the older Windows-path guess to normal JSON semantics.
  • [P1] parseStreamingJson is used by several provider transports and exported through src/plugin-sdk/llm.ts, so this small parser fix still needs maintainer acceptance as a compatibility-sensitive behavior change.
  • [P1] The open adjacent parser PR at fix(json-parse): exclude code-context tails from Windows-path heuristic (#93139) #95982 changes the same Windows-path heuristic for a different false positive, so maintainers should review the combined rule if both land.

Maintainer options:

  1. Accept the documented parser rule (recommended)
    A parser/runtime owner can approve that escaped-backslash evidence wins for the rest of a string, then land this PR with its regression tests.
  2. Narrow the suppression signal first
    If malformed mixed-escaping compatibility must be preserved, ask for a follow-up patch that ties the trust signal more tightly to the matched path segment.
  3. Coordinate adjacent heuristic work
    Review this PR together with fix(json-parse): exclude code-context tails from Windows-path heuristic (#93139) #95982 before landing both parser heuristic changes.

Next step before merge

  • [P2] Manual review remains because the remaining action is owner acceptance of a compatibility-sensitive shared parser heuristic, not an automated code repair.

Security
Cleared: No concrete security or supply-chain concern was found; the diff only changes local parser logic and colocated tests.

Review details

Best possible solution:

Land the shared parser fix after a parser/runtime owner accepts the documented mixed-escaping semantics; keep this behavior centralized in the parser rather than adding provider-specific shims.

Do we have a high-confidence way to reproduce the issue?

Yes. I did not execute the repro in this read-only review, but current main and v2026.6.10 source still apply the Windows-path heuristic without escaped-backslash evidence, and the PR body includes fail-before/pass-after terminal proof against the exported parser functions.

Is this the best way to solve the issue?

Yes, with compatibility owner acceptance. The shared parser is the right layer; a JSON.parse-first guard would regress pinned unescaped Windows-path repair, and provider-specific fixes would duplicate the shared contract.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 69af58ba2613.

Label changes

Label justifications:

  • P2: This is a focused shared parser bug fix for possible tool-call argument corruption, with limited scope and no outage or security-bypass signal.
  • merge-risk: 🚨 compatibility: The diff intentionally changes repair semantics for malformed mixed-escaping JSON strings that can come from model or plugin tool-call arguments.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body provides after-fix terminal proof using real exported parser functions, old-vs-new differential output, fail-before evidence, fuzz output, and focused Vitest output.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body provides after-fix terminal proof using real exported parser functions, old-vs-new differential output, fail-before evidence, fuzz output, and focused Vitest output.
Evidence reviewed

PR surface:

Source +16, Tests +14. Total +30 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 18 2 +16
Tests 1 14 0 +14
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 32 2 +30

What I checked:

  • Repository policy applied: Root AGENTS.md was read fully, and src/agents/AGENTS.md plus src/plugin-sdk/AGENTS.md were read because the parser is consumed by agent transports and exported through the plugin SDK. (AGENTS.md:21, 69af58ba2613)
  • Current main still has the reported behavior: Current main gates control escapes only on looksLikeWindowsPathPrefix(stringValuePrefix), with no per-string escaped-backslash evidence flag, so the central bug is not already solved on main. (src/llm/utils/json-parse.ts:82, 69af58ba2613)
  • Latest release still has the same parser rule: v2026.6.10 contains the same Windows-path control-escape branch without the escaped-backslash guard, so the proposed behavior has not shipped in the latest release. (src/llm/utils/json-parse.ts:82, aa69b12d0086)
  • PR patch shape: The PR adds sawEscapedBackslash, resets it at string boundaries, gates the Windows-path heuristic on it, and sets it only when a valid escaped backslash is consumed. (src/llm/utils/json-parse.ts:35, 1cbdeab3f3a9)
  • Regression coverage: The PR adds coverage for an escaped Windows path followed by real newline and tab escapes through parseJsonWithRepair and parseStreamingJson. (src/llm/utils/json-parse.test.ts:31, 1cbdeab3f3a9)
  • Shared runtime caller surface: OpenAI Responses, OpenAI transport, OpenAI completions, Mistral, Anthropic fallback, Bedrock, and runtime proxy paths call parseStreamingJson, so this is a shared parser compatibility surface rather than one provider-only path. (src/llm/providers/openai-responses-shared.ts:780, 69af58ba2613)

Likely related people:

  • steipete: PR history and git show tie commit bb4ae914da486fb17504e880b6f2e0b51c2e97c6 to the Windows-path control-escape heuristic and tests that this PR narrows. (role: introduced current Windows-path heuristic; confidence: high; commits: bb4ae914da48, 9ead0ae9219e; files: src/llm/utils/json-parse.ts, src/llm/utils/json-parse.test.ts)
  • vincentkoc: Commit b579c0a65be1725c7d4a915a310426c35b94793a recently changed parseStreamingJson normalization in the same parser utility, and commit metadata maps it to this GitHub login. (role: recent adjacent parser contributor; confidence: medium; commits: b579c0a65be1; files: src/llm/utils/json-parse.ts, src/llm/utils/json-parse.test.ts)
  • coder999999999: Merged PR history for commit adcac404e147834f49583aad7cfb06329a3bad90 added nearby invalid-unicode escape repair coverage in the same parser utility. (role: earlier parser repair contributor; confidence: medium; commits: adcac404e147; files: src/llm/utils/json-parse.ts, src/llm/utils/json-parse.test.ts)
  • mushuiyu886: Current checkout blame points the parser lines at commit a7bfc06f4597ee946cbc066cb41750a7e8f59c52, though that broader commit appears to have carried the existing parser forward rather than designed the Windows-path rule. (role: recent line owner; confidence: low; commits: a7bfc06f4597; files: src/llm/utils/json-parse.ts, src/llm/utils/json-parse.test.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. labels Jun 25, 2026
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

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Jul 12, 2026
@openclaw-barnacle

Copy link
Copy Markdown

Closing due to inactivity.
If you believe this PR should be revived, post in #clawtributors on Discord to talk to a maintainer.
That channel is the escape hatch for high-quality PRs that get auto-closed.

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

Labels

merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: XS stale Marked as stale due to inactivity status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant