fix: comprehensive tool argument normalization hardening

- Remove all { raw: ... } returns that caused InputValidationError with
  z.strictObject schemas — return {} instead for clean Zod errors
- Extend normalizeAtStop buffering to all mapped tools (Read, Write,
  Edit, Glob, Grep) so streaming paths also get normalized
- Make repairPossiblyTruncatedObjectJson generic — repair any valid
  JSON object, not just ones with a command field
- Export hasToolFieldMapping for streaming normalizeAtStop decision
- Skip normalization on finish_reason: length to preserve raw truncated
  buffer
- Update all test expectations to match new behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gnanam1990
2026-04-06 18:38:33 +05:30
parent 50efbe5614
commit 3a25d71004
4 changed files with 35 additions and 44 deletions

View File

@@ -744,7 +744,7 @@ test('keeps terminal empty Bash tool arguments invalid in non-streaming response
type: 'tool_use',
id: 'function-call-1',
name: 'Bash',
input: { raw: '' },
input: {},
},
])
})
@@ -1094,7 +1094,7 @@ test('keeps terminal whitespace-only Bash arguments invalid in streaming respons
.map(event => (event.delta as Record<string, unknown>).partial_json)
.join('')
expect(normalizedInput).toBe('{"raw":" "}')
expect(normalizedInput).toBe('{}')
})
test('normalizes streaming Bash arguments that begin with bracket syntax', async () => {
@@ -1423,7 +1423,7 @@ test('does not normalize incomplete streamed Bash commands when finish_reason is
expect(streamedInput).toBe('rg --fi')
})
test('does not repair truncated Bash objects that do not contain command', async () => {
test('repairs truncated JSON objects even without command field', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
@@ -1496,7 +1496,7 @@ test('does not repair truncated Bash objects that do not contain command', async
.map(event => (event.delta as Record<string, unknown>).partial_json)
.join('')
expect(streamedInput).toBe('{"raw":"{\\"cwd\\":\\"/tmp\\""}')
expect(streamedInput).toBe('{"cwd":"/tmp"}')
})
test('preserves raw input for unknown plain string tool arguments', async () => {
@@ -1554,7 +1554,7 @@ test('preserves raw input for unknown plain string tool arguments', async () =>
type: 'tool_use',
id: 'function-call-1',
name: 'UnknownTool',
input: { raw: 'pwd' },
input: {},
},
])
})

View File

@@ -44,6 +44,7 @@ import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js'
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
import {
normalizeToolArguments,
hasToolFieldMapping,
} from './toolArgumentNormalization.js'
type SecretValueSource = Partial<{
@@ -486,7 +487,7 @@ const JSON_REPAIR_SUFFIXES = [
function repairPossiblyTruncatedObjectJson(raw: string): string | null {
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && typeof (parsed as Record<string, unknown>).command === 'string'
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? raw
: null
} catch {
@@ -494,12 +495,7 @@ function repairPossiblyTruncatedObjectJson(raw: string): string | null {
try {
const repaired = raw + combo
const parsed = JSON.parse(repaired)
if (
parsed &&
typeof parsed === 'object' &&
!Array.isArray(parsed) &&
typeof (parsed as Record<string, unknown>).command === 'string'
) {
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return repaired
}
} catch {}
@@ -619,7 +615,7 @@ async function* openaiStreamToAnthropic(
const toolBlockIndex = contentBlockIndex
const initialArguments = tc.function.arguments ?? ''
const normalizeAtStop = tc.function.name === 'Bash'
const normalizeAtStop = hasToolFieldMapping(tc.function.name)
activeToolCalls.set(tc.index, {
id: tc.id,
name: tc.function.name,

View File

@@ -19,20 +19,18 @@ describe('normalizeToolArguments', () => {
).toEqual({ command: 'echo hi' })
})
test('returns { raw } for blank string', () => {
expect(normalizeToolArguments('Bash', '')).toEqual({ raw: '' })
expect(normalizeToolArguments('Bash', ' ')).toEqual({ raw: ' ' })
test('returns empty object for blank string', () => {
expect(normalizeToolArguments('Bash', '')).toEqual({})
expect(normalizeToolArguments('Bash', ' ')).toEqual({})
})
test('returns { raw } for JSON-encoded blank string', () => {
expect(normalizeToolArguments('Bash', '""')).toEqual({ raw: '' })
expect(normalizeToolArguments('Bash', '" "')).toEqual({ raw: ' ' })
test('returns parsed blank for JSON-encoded blank string', () => {
expect(normalizeToolArguments('Bash', '""')).toEqual('')
expect(normalizeToolArguments('Bash', '" "')).toEqual(' ')
})
test('returns { raw } for likely structured object literal that fails parse', () => {
expect(normalizeToolArguments('Bash', '{ "command": "pwd"')).toEqual({
raw: '{ "command": "pwd"',
})
test('returns empty object for malformed structured object literal', () => {
expect(normalizeToolArguments('Bash', '{ "command": "pwd"')).toEqual({})
})
test.each([
@@ -40,9 +38,9 @@ describe('normalizeToolArguments', () => {
["{'command':'pwd'}"],
['{command: pwd}'],
])(
'returns { raw } for malformed object-shaped string %s (does not wrap into command)',
'returns empty object for malformed object-shaped string %s (does not wrap into command)',
(input) => {
expect(normalizeToolArguments('Bash', input)).toEqual({ raw: input })
expect(normalizeToolArguments('Bash', input)).toEqual({})
},
)
@@ -157,10 +155,8 @@ describe('normalizeToolArguments', () => {
})
describe('unknown tools', () => {
test('returns { raw } for plain string', () => {
expect(normalizeToolArguments('UnknownTool', 'some value')).toEqual({
raw: 'some value',
})
test('returns empty object for plain string (no known field mapping)', () => {
expect(normalizeToolArguments('UnknownTool', 'some value')).toEqual({})
})
test('passes through structured JSON object', () => {
@@ -175,7 +171,7 @@ describe('normalizeToolArguments', () => {
expect(normalizeToolArguments('UnknownTool', '[]')).toEqual([])
})
test('returns JSON-encoded string as parsed string for unknown tools', () => {
test('returns parsed string for JSON-encoded string on unknown tools', () => {
expect(normalizeToolArguments('UnknownTool', '"hello"')).toEqual(
'hello',
)

View File

@@ -26,6 +26,10 @@ function getPlainStringToolArgumentField(toolName: string): string | null {
return STRING_ARGUMENT_TOOL_FIELDS[toolName] ?? null
}
export function hasToolFieldMapping(toolName: string): boolean {
return toolName in STRING_ARGUMENT_TOOL_FIELDS
}
function wrapPlainStringToolArguments(
toolName: string,
value: string,
@@ -46,25 +50,20 @@ export function normalizeToolArguments(
if (isRecord(parsed)) {
return parsed
}
if (toolName === 'Bash') {
if (typeof parsed === 'string') {
if (isBlankString(parsed)) {
return { raw: parsed }
}
return wrapPlainStringToolArguments(toolName, parsed) ?? parsed
}
return parsed
}
if (typeof parsed === 'string') {
// Parsed as a non-object JSON value (string, number, boolean, null, array)
if (typeof parsed === 'string' && !isBlankString(parsed)) {
return wrapPlainStringToolArguments(toolName, parsed) ?? parsed
}
// For blank strings, booleans, null, arrays — pass through as-is
// and let Zod schema validation produce a meaningful error
return parsed
} catch {
// rawArguments is not valid JSON — treat as a plain string
if (isBlankString(rawArguments) || isLikelyStructuredObjectLiteral(rawArguments)) {
return { raw: rawArguments }
// Blank or looks like a malformed object literal — don't wrap into
// a tool field to avoid turning garbage into executable input
return {}
}
return (
wrapPlainStringToolArguments(toolName, rawArguments) ?? { raw: rawArguments }
)
return wrapPlainStringToolArguments(toolName, rawArguments) ?? {}
}
}