Repository navigation
Truncated tool-call arguments can be silently written to disk (stale partial-parse nativeArgs reused on finalization failure) #1221
Description
Summary
When a streamed tool call's arguments are cut off mid-value (e.g. the model hits max_tokens while still writing a string argument), the value that was only ever meant for live streaming-progress display can end up used as the final, executed value with no error or diagnostic indicating it was incomplete.
Where
Observed at commit abaf732bb7a96848a7c6f5dd2ce485b86bff086d.
src/core/assistant-message/NativeToolCallParser.ts:processStreamingChunk()uses thepartial-jsonpackage (^0.1.7insrc/package.json) to extract a best-effort value from the JSON accumulated so far, purely to show progress during streaming -createPartialToolUse()'swrite_to_filecase buildsnativeArgs.contentdirectly from that lenient parse.- When the stream ends,
finalizeStreamingToolCall()calls the strictparseToolCall()(JSON.parseon the full accumulated text). A genuinely truncated payload correctly throws and the function returnsnull. src/core/task/Task.ts(search forfinalizeStreamingToolCall returned null): when finalization returnsnull, the code reuses the existing tool-use object - the one built during streaming from thepartial-jsonoutput - and just sets.partial = false. It does not clearnativeArgs.src/core/assistant-message/presentAssistantMessage.ts: the guard here is!block.nativeArgs. SincenativeArgswas already populated during streaming, this guard does not trip.src/core/tools/WriteToFileTool.ts: the only content check isnewContent === undefined. A truncated-but-present string passes this too.
Repro / PoC
Given a write_to_file call whose content argument is cut off mid-value:
{"path":"config/database.yml","content":"production:\n host: db.prod.internal\n password: correct-horse-battery-sta
Running the actual partial-json package (v0.1.7, the version pinned in src/package.json) on this string:
parse(text) -> { path: "config/database.yml",
content: "production:\n host: db.prod.internal\n password: correct-horse-battery-sta" }
No error. content is present and looks like a normal, complete string - exactly what nativeArgs.content would still hold when finalization fails and the stale streaming-phase object is reused, so WriteToFileTool would write this content to disk exactly as shown, silently.
For contrast, running the same input through prefix-safe-json (an incremental JSON parser I maintain, built specifically around never exposing a value until it's unambiguously complete):
outcome: "truncated", executable: false
stableValue: { path: "config/database.yml" } // content is absent, not guessed
Impact
Any tool whose schema includes a free-form string argument (file content, shell command, etc.) can have that argument silently truncated-but-executed when the model's response is cut off mid-argument - a data-integrity issue for any tool with a real side effect.
Context
This codebase shares this exact code path (including comments) with RooCodeInc/Roo-Code, which this appears to be derived from - I reported the same issue there as well, in case it's useful for cross-referencing a fix.
Suggested direction (not prescriptive)
When finalizeStreamingToolCall() returns null, clear/discard the reused tool-use object's nativeArgs (and ideally params) rather than only marking it non-partial - falling back to the existing !block.nativeArgs guard at that point instead of leaving stale partial-parse data in place.
I found this by cloning the repository and reading the real source (paths/lines above), then verified the specific claim about partial-json's output by running the actual package version you depend on - not speculation. Happy to answer questions or share the small repro script if useful.
Activity
canblmz1 commentedon Sep 14, 2026
Traced this down to `Task.ts`, where `finalizeStreamingToolCall()` returning `null` (truncated/malformed streamed tool-call JSON) leaves the in-progress tool-use object's `nativeArgs` populated from the incomplete partial parse, only setting `partial = false`. That defeats the existing fail-closed guard in `presentAssistantMessage.ts` (`isKnownTool && !block.nativeArgs && !customTool`), which never actually fires because `nativeArgs` is never cleared - so truncated arguments can be executed instead of rejected.
Opened a fix with a regression test: #1634
test(task): add integration coverage for truncated write_to_file fina…
fix(task): clear nativeArgs when tool-call finalize fails (Zoo-Code-O…
test(task): add integration coverage for truncated write_to_file fina…
fix(task): also clear params on truncated tool-call finalize (Zoo-Cod…
fix: clear nativeArgs when tool-call finalize fails (Zoo-Code-Org#1221)…
fix(core): validate native mcp_tool_use calls against the request pol…
fix: validate tools against the request's policy snapshot instead of …