Skip to content

Commit 4f2c00a

Browse files
HXYerrorclaude
andcommitted
fix(messages): address crew review round 2 findings for #8/#9
- Move stop_sequences comment to correct location (output object, not image branch) - Add runtime narrowing guard in handleAnthropicViaResponses to replace unsafe cast - Propagate failed upstream status as 500 instead of 200 OK - Apply media_type allowlist to non-stream-translation.ts (was only in Responses path) - Apply prototype pollution guard to non-stream-translation.ts tool arg parsing - Hoist DANGEROUS_KEYS to module-level constant in responses-to-anthropic.ts - Add DANGEROUS_TOOL_KEYS module-level constant in non-stream-translation.ts - Validate output_config.effort against allowlist before forwarding (new in R1 fix) Test additions: - Image with disallowed media_type (svg+xml) → dropped, text block preserved - function_call with array JSON arguments → wrapped in _raw (non-object guard) - function_call with constructor/prototype keys → stripped - Fix vacuous-pass risk: assert block.type before narrowing in tool_use tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c7297e3 commit 4f2c00a

6 files changed

Lines changed: 194 additions & 32 deletions

File tree

src/routes/messages/anthropic-to-responses.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ const ALLOWED_IMAGE_MEDIA_TYPES = new Set([
3838
"image/webp",
3939
])
4040

41+
// ---------------------------------------------------------------------------
42+
// Allowlisted reasoning effort values
43+
// ---------------------------------------------------------------------------
44+
45+
const VALID_EFFORT_VALUES = new Set<string>(["low", "medium", "high"])
46+
4147
// ---------------------------------------------------------------------------
4248
// Budget → effort tier mapping
4349
// ---------------------------------------------------------------------------
@@ -152,9 +158,6 @@ function translateUserMessage(
152158
// image block
153159
const imagePart = translateImageBlock(block)
154160
if (imagePart) contentParts.push(imagePart)
155-
// Note: stop_sequences are also not forwarded — Responses API has no
156-
// equivalent field; callers relying on stop sequences will not get that
157-
// behaviour when using Responses-only models.
158161
}
159162
}
160163

@@ -296,8 +299,13 @@ function translateReasoning(
296299
if (!thinking) return undefined
297300

298301
if (thinking.type === "adaptive") {
299-
// Use explicit effort from output_config when provided; default to medium
300-
return { effort: outputConfig?.effort ?? "medium" }
302+
const rawEffort = outputConfig?.effort
303+
// Validate effort value against allowlist to prevent forwarding arbitrary strings
304+
const effort =
305+
rawEffort !== undefined && VALID_EFFORT_VALUES.has(rawEffort) ?
306+
rawEffort
307+
: "medium"
308+
return { effort: effort }
301309
}
302310

303311
// type === "enabled"
@@ -337,5 +345,8 @@ export function translateAnthropicToResponses(
337345
reasoning: translateReasoning(payload.thinking, payload.output_config),
338346
stream: payload.stream,
339347
user: payload.metadata?.user_id,
348+
// NOTE: stop_sequences is not forwarded — the Responses API has no
349+
// equivalent field. Callers relying on stop sequences will not get that
350+
// behaviour when using Responses-only models.
340351
}
341352
}

src/routes/messages/handler.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import type { Context } from "hono"
33
import consola from "consola"
44
import { streamSSE } from "hono/streaming"
55

6-
import type { ResponsesResponse } from "~/routes/responses/types"
7-
86
import { awaitApproval } from "~/lib/approval"
97
import { getModelMode } from "~/lib/model-routing"
108
import { checkRateLimit } from "~/lib/rate-limit"
@@ -136,7 +134,41 @@ async function handleAnthropicViaResponses(
136134
...responsesPayload,
137135
stream: false,
138136
})
139-
const sanitised = sanitiseResponsesOutput(rawResponse as ResponsesResponse)
137+
138+
// Runtime guard: createResponses returns ResponsesResponse for stream:false,
139+
// but TypeScript types the return as a union — narrow explicitly.
140+
if (!("output" in rawResponse)) {
141+
consola.error(
142+
"Unexpected non-response shape from createResponses:",
143+
rawResponse,
144+
)
145+
return c.json(
146+
{
147+
error: {
148+
message: "Upstream returned unexpected response shape",
149+
type: "api_error",
150+
code: "invalid_upstream_response",
151+
},
152+
},
153+
502,
154+
)
155+
}
156+
157+
const typedResponse = rawResponse
158+
159+
// Surface upstream terminal failures as errors instead of 200 OK
160+
if (typedResponse.status === "failed") {
161+
const errMsg =
162+
(typedResponse as unknown as { error?: { message?: string } }).error
163+
?.message ?? "Upstream model call failed"
164+
consola.error("Responses API returned failed status:", errMsg)
165+
return c.json(
166+
{ error: { message: errMsg, type: "api_error", code: "model_error" } },
167+
500,
168+
)
169+
}
170+
171+
const sanitised = sanitiseResponsesOutput(typedResponse)
140172
const anthropicResponse = translateResponsesToAnthropic(sanitised)
141173

142174
consola.debug(

src/routes/messages/non-stream-translation.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import consola from "consola"
22

3+
// Allowlisted image media types for data URI construction (injection guard)
4+
const ALLOWED_IMAGE_MEDIA_TYPES = new Set([
5+
"image/jpeg",
6+
"image/png",
7+
"image/gif",
8+
"image/webp",
9+
])
10+
11+
// Keys that trigger prototype pollution — stripped from upstream tool arguments
12+
const DANGEROUS_TOOL_KEYS = new Set(["__proto__", "constructor", "prototype"])
13+
314
import {
415
type ChatCompletionResponse,
516
type ChatCompletionsPayload,
@@ -216,12 +227,19 @@ function mapContent(
216227
}
217228
case "image": {
218229
if (block.source.type === "base64") {
219-
contentParts.push({
220-
type: "image_url",
221-
image_url: {
222-
url: `data:${block.source.media_type};base64,${block.source.data}`,
223-
},
224-
})
230+
if (ALLOWED_IMAGE_MEDIA_TYPES.has(block.source.media_type)) {
231+
contentParts.push({
232+
type: "image_url",
233+
image_url: {
234+
url: `data:${block.source.media_type};base64,${block.source.data}`,
235+
},
236+
})
237+
} else {
238+
consola.warn(
239+
"Skipping image with unsupported media_type in translation path:",
240+
block.source.media_type,
241+
)
242+
}
225243
} else {
226244
// URL images are rejected by Copilot upstream — skip silently
227245
// (type kept for fidelity when round-tripping through native path)
@@ -370,10 +388,28 @@ function getAnthropicToolUseBlocks(
370388
if (!toolCalls) {
371389
return []
372390
}
373-
return toolCalls.map((toolCall) => ({
374-
type: "tool_use",
375-
id: toolCall.id,
376-
name: toolCall.function.name,
377-
input: JSON.parse(toolCall.function.arguments) as Record<string, unknown>,
378-
}))
391+
return toolCalls.map((toolCall) => {
392+
let parsedInput: Record<string, unknown>
393+
try {
394+
const raw: unknown = JSON.parse(toolCall.function.arguments)
395+
// Non-object JSON (array, number, null, etc.) → wrap in _raw
396+
parsedInput =
397+
typeof raw !== "object" || raw === null || Array.isArray(raw) ?
398+
{ _raw: toolCall.function.arguments }
399+
// Strip prototype-pollution keys before forwarding
400+
: Object.fromEntries(
401+
Object.entries(raw as Record<string, unknown>).filter(
402+
([k]) => !DANGEROUS_TOOL_KEYS.has(k),
403+
),
404+
)
405+
} catch {
406+
parsedInput = { _raw: toolCall.function.arguments }
407+
}
408+
return {
409+
type: "tool_use",
410+
id: toolCall.id,
411+
name: toolCall.function.name,
412+
input: parsedInput,
413+
}
414+
})
379415
}

src/routes/messages/responses-to-anthropic.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
* Anthropic clients understand.
55
*/
66

7+
// Keys that trigger prototype pollution — stripped from upstream tool arguments
8+
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"])
9+
710
import type {
811
ResponsesOutputFunctionCall,
912
ResponsesOutputMessage,
@@ -89,18 +92,17 @@ function translateFunctionCallItem(
8992
let parsedInput: Record<string, unknown>
9093
try {
9194
const raw: unknown = JSON.parse(item.arguments)
92-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
93-
parsedInput = { _raw: item.arguments }
94-
} else {
95-
// Strip prototype-pollution keys before forwarding.
96-
// Use Object.entries to avoid inherited properties and __proto__ setter tricks.
97-
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"])
98-
parsedInput = Object.fromEntries(
99-
Object.entries(raw as Record<string, unknown>).filter(
100-
([k]) => !DANGEROUS_KEYS.has(k),
101-
),
102-
)
103-
}
95+
// Non-object JSON (array, number, null, etc.) → wrap in _raw
96+
parsedInput =
97+
typeof raw !== "object" || raw === null || Array.isArray(raw) ?
98+
{ _raw: item.arguments }
99+
// Strip prototype-pollution keys before forwarding.
100+
// Use Object.entries to avoid inherited properties and __proto__ setter tricks.
101+
: Object.fromEntries(
102+
Object.entries(raw as Record<string, unknown>).filter(
103+
([k]) => !DANGEROUS_KEYS.has(k),
104+
),
105+
)
104106
} catch {
105107
// If arguments are not valid JSON, wrap as a string
106108
parsedInput = { _raw: item.arguments }

tests/anthropic-to-responses.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,39 @@ describe("translateAnthropicToResponses — basic messages", () => {
9494
expect(parts[0].image_url).toBe("data:image/png;base64,abc123")
9595
})
9696

97+
test("image block with disallowed media_type → dropped (injection guard)", () => {
98+
const result = translateAnthropicToResponses(
99+
makeBasePayload({
100+
messages: [
101+
{
102+
role: "user",
103+
content: [
104+
{
105+
type: "text",
106+
text: "Look at this",
107+
},
108+
{
109+
type: "image",
110+
source: {
111+
type: "base64",
112+
// svg+xml is not in the allowlist
113+
media_type: "image/svg+xml" as "image/png",
114+
data: "evil-data",
115+
},
116+
},
117+
],
118+
},
119+
],
120+
}),
121+
)
122+
const items = result.input as Array<unknown>
123+
// Only the text block should survive; the disallowed image is dropped
124+
const msg = items[0] as ResponsesInputMessage
125+
const parts = msg.content as Array<{ type: string }>
126+
expect(parts.every((p) => p.type !== "input_image")).toBe(true)
127+
expect(parts.some((p) => p.type === "input_text")).toBe(true)
128+
})
129+
97130
test("user message with tool_result → function_call_output item", () => {
98131
const result = translateAnthropicToResponses(
99132
makeBasePayload({

tests/responses-to-anthropic.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,34 @@ describe("translateResponsesToAnthropic — function_call items", () => {
219219
],
220220
})
221221
const result = translateResponsesToAnthropic(response)
222+
expect(result.content[0].type).toBe("tool_use")
222223
const block = result.content[0]
223224
if (block.type === "tool_use") {
224225
expect(block.input).toEqual({ _raw: "not-json" })
225226
}
226227
})
227228

229+
test("function_call with array JSON arguments → wrapped in _raw (non-object guard)", () => {
230+
const response = makeResponse({
231+
output: [
232+
{
233+
type: "function_call",
234+
id: "fc_arr",
235+
call_id: "call_arr",
236+
name: "array_tool",
237+
arguments: JSON.stringify([1, 2, 3]),
238+
status: "completed",
239+
},
240+
],
241+
})
242+
const result = translateResponsesToAnthropic(response)
243+
expect(result.content[0].type).toBe("tool_use")
244+
const block = result.content[0]
245+
if (block.type === "tool_use") {
246+
expect(block.input).toEqual({ _raw: "[1,2,3]" })
247+
}
248+
})
249+
228250
test("function_call with __proto__ in arguments → stripped (prototype pollution guard)", () => {
229251
const response = makeResponse({
230252
output: [
@@ -242,12 +264,38 @@ describe("translateResponsesToAnthropic — function_call items", () => {
242264
],
243265
})
244266
const result = translateResponsesToAnthropic(response)
267+
expect(result.content[0].type).toBe("tool_use")
245268
const block = result.content[0]
246269
if (block.type === "tool_use") {
247270
expect(Object.hasOwn(block.input, "__proto__")).toBe(false)
248271
expect(block.input.city).toBe("London")
249272
}
250273
})
274+
275+
test("function_call with constructor/prototype keys → stripped", () => {
276+
const args =
277+
'{"constructor":{"name":"hacked"},"prototype":{"x":1},"value":42}'
278+
const response = makeResponse({
279+
output: [
280+
{
281+
type: "function_call",
282+
id: "fc_4",
283+
call_id: "call_ctor",
284+
name: "ctor_tool",
285+
arguments: args,
286+
status: "completed",
287+
},
288+
],
289+
})
290+
const result = translateResponsesToAnthropic(response)
291+
expect(result.content[0].type).toBe("tool_use")
292+
const block = result.content[0]
293+
if (block.type === "tool_use") {
294+
expect(Object.hasOwn(block.input, "constructor")).toBe(false)
295+
expect(Object.hasOwn(block.input, "prototype")).toBe(false)
296+
expect(block.input.value).toBe(42)
297+
}
298+
})
251299
})
252300

253301
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)