Skip to content

Commit 4f0c595

Browse files
committed
fix: prevent write_to_file from executing with missing content parameter
When finalizeStreamingToolCall returns null (malformed/incomplete args from LLM), the existing partial ToolUse block was promoted to non-partial while retaining stale nativeArgs from partial parsing. The partial parser for write_to_file used OR logic (path || content), allowing nativeArgs to be set with only path present. This bypassed the safety check in presentAssistantMessage that guards against missing nativeArgs. Two fixes applied: 1. Clear nativeArgs when promoting failed finalization blocks to complete in Task.ts (both finalization paths). This ensures the safety check in presentAssistantMessage properly catches invalid tool calls. 2. Tighten partial write_to_file nativeArgs in NativeToolCallParser to require both path AND content (handlePartial reads from params, not nativeArgs, so this is safe). Closes #12079
1 parent 7adbfec commit 4f0c595

3 files changed

Lines changed: 116 additions & 3 deletions

File tree

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ export class NativeToolCallParser {
465465
break
466466

467467
case "write_to_file":
468-
if (partialArgs.path || partialArgs.content) {
468+
if (partialArgs.path !== undefined && partialArgs.content !== undefined) {
469469
nativeArgs = {
470470
path: partialArgs.path,
471471
content: partialArgs.content,

src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,107 @@ describe("NativeToolCallParser", () => {
313313
})
314314
})
315315

316+
describe("write_to_file partial streaming", () => {
317+
it("should not set nativeArgs when only path is present (content missing)", () => {
318+
const id = "toolu_wtf_partial_1"
319+
NativeToolCallParser.startStreamingToolCall(id, "write_to_file")
320+
321+
// Simulate partial chunk with only path, no content yet
322+
const result = NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ path: "test.txt" }))
323+
324+
// nativeArgs should NOT be set when content is missing
325+
expect(result).not.toBeNull()
326+
if (result) {
327+
expect(result.nativeArgs).toBeUndefined()
328+
// params should still have path for UI display
329+
expect(result.params.path).toBe("test.txt")
330+
}
331+
})
332+
333+
it("should set nativeArgs when both path and content are present", () => {
334+
const id = "toolu_wtf_partial_2"
335+
NativeToolCallParser.startStreamingToolCall(id, "write_to_file")
336+
337+
const result = NativeToolCallParser.processStreamingChunk(
338+
id,
339+
JSON.stringify({ path: "test.txt", content: "hello world" }),
340+
)
341+
342+
expect(result).not.toBeNull()
343+
if (result) {
344+
expect(result.nativeArgs).toBeDefined()
345+
const nativeArgs = result.nativeArgs as { path: string; content: string }
346+
expect(nativeArgs.path).toBe("test.txt")
347+
expect(nativeArgs.content).toBe("hello world")
348+
}
349+
})
350+
})
351+
352+
describe("write_to_file parseToolCall (complete)", () => {
353+
it("should return null when content is missing from complete tool call", () => {
354+
const toolCall = {
355+
id: "toolu_wtf_complete_1",
356+
name: "write_to_file" as const,
357+
arguments: JSON.stringify({ path: "test.txt" }),
358+
}
359+
360+
// parseToolCall throws internally and returns null for invalid args
361+
const result = NativeToolCallParser.parseToolCall(toolCall)
362+
expect(result).toBeNull()
363+
})
364+
365+
it("should parse correctly when both path and content are present", () => {
366+
const toolCall = {
367+
id: "toolu_wtf_complete_2",
368+
name: "write_to_file" as const,
369+
arguments: JSON.stringify({ path: "test.txt", content: "hello world" }),
370+
}
371+
372+
const result = NativeToolCallParser.parseToolCall(toolCall)
373+
expect(result).not.toBeNull()
374+
expect(result?.type).toBe("tool_use")
375+
if (result?.type === "tool_use") {
376+
expect(result.nativeArgs).toBeDefined()
377+
const nativeArgs = result.nativeArgs as { path: string; content: string }
378+
expect(nativeArgs.path).toBe("test.txt")
379+
expect(nativeArgs.content).toBe("hello world")
380+
}
381+
})
382+
})
383+
384+
describe("write_to_file finalization fallback", () => {
385+
it("should return null when finalized with missing content", () => {
386+
const id = "toolu_wtf_finalize_1"
387+
NativeToolCallParser.startStreamingToolCall(id, "write_to_file")
388+
389+
// Stream only path, no content
390+
NativeToolCallParser.processStreamingChunk(id, JSON.stringify({ path: "test.txt" }))
391+
392+
// Finalization should fail (return null) because content is missing
393+
const result = NativeToolCallParser.finalizeStreamingToolCall(id)
394+
expect(result).toBeNull()
395+
})
396+
397+
it("should finalize successfully when both path and content are present", () => {
398+
const id = "toolu_wtf_finalize_2"
399+
NativeToolCallParser.startStreamingToolCall(id, "write_to_file")
400+
401+
NativeToolCallParser.processStreamingChunk(
402+
id,
403+
JSON.stringify({ path: "test.txt", content: "file content" }),
404+
)
405+
406+
const result = NativeToolCallParser.finalizeStreamingToolCall(id)
407+
expect(result).not.toBeNull()
408+
expect(result?.type).toBe("tool_use")
409+
if (result?.type === "tool_use") {
410+
const nativeArgs = result.nativeArgs as { path: string; content: string }
411+
expect(nativeArgs.path).toBe("test.txt")
412+
expect(nativeArgs.content).toBe("file content")
413+
}
414+
})
415+
})
416+
316417
describe("finalizeStreamingToolCall", () => {
317418
describe("read_file tool", () => {
318419
it("should parse read_file args on finalize", () => {

src/core/task/Task.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2967,6 +2967,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
29672967
const existingToolUse = this.assistantMessageContent[toolUseIndex]
29682968
if (existingToolUse && existingToolUse.type === "tool_use") {
29692969
existingToolUse.partial = false
2970+
// Clear stale nativeArgs from partial parsing so the safety check
2971+
// in presentAssistantMessage (!block.nativeArgs) properly catches
2972+
// this as an invalid tool call. Without this, incomplete partial
2973+
// nativeArgs (e.g., path set but content undefined) would bypass
2974+
// the check and cause tools to execute with missing parameters.
2975+
existingToolUse.nativeArgs = undefined
29702976
// Ensure it has the ID for native protocol
29712977
;(existingToolUse as any).id = event.id
29722978
}
@@ -3350,11 +3356,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
33503356
presentAssistantMessage(this)
33513357
} else if (toolUseIndex !== undefined) {
33523358
// finalizeStreamingToolCall returned null (malformed JSON or missing args)
3353-
// We still need to mark the tool as non-partial so it gets executed
3354-
// The tool's validation will catch any missing required parameters
3359+
// We still need to mark the tool as non-partial so it gets presented.
3360+
// The presentAssistantMessage safety check will catch missing nativeArgs.
33553361
const existingToolUse = this.assistantMessageContent[toolUseIndex]
33563362
if (existingToolUse && existingToolUse.type === "tool_use") {
33573363
existingToolUse.partial = false
3364+
// Clear stale nativeArgs from partial parsing so the safety check
3365+
// in presentAssistantMessage (!block.nativeArgs) properly catches
3366+
// this as an invalid tool call. Without this, incomplete partial
3367+
// nativeArgs (e.g., path set but content undefined) would bypass
3368+
// the check and cause tools to execute with missing parameters.
3369+
existingToolUse.nativeArgs = undefined
33583370
// Ensure it has the ID for native protocol
33593371
;(existingToolUse as any).id = event.id
33603372
}

0 commit comments

Comments
 (0)