diff --git a/src/vision/describe.ts b/src/vision/describe.ts index 68744fd90..4bbc198ee 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -16,6 +16,8 @@ export interface VisionSettings { export type DescribeOutcome = { text: string; error?: string }; const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]); +const MAX_MIME_CHARS = 127; +const MIME_PATTERN = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i; /** ~20 MB — generous enough for screenshots; rejects pathological payloads before forwarding. */ const MAX_IMAGE_BYTES = 20 * 1024 * 1024; @@ -29,6 +31,7 @@ function validateImageUrl(url: string): string | null { if (url.startsWith("data:")) { const m = /^data:([^;,]+?)(;base64)?,(.*)$/s.exec(url); if (!m) return "malformed data URL"; + if (m[1].length > MAX_MIME_CHARS || !MIME_PATTERN.test(m[1])) return "invalid image media type"; const mime = m[1].toLowerCase(); if (!ALLOWED_IMAGE_MIME.has(mime)) return `unsupported image type "${mime}"`; if (m[2]) { diff --git a/src/vision/index.ts b/src/vision/index.ts index f44b713ec..c6d607274 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -160,6 +160,10 @@ function clamp(s: string, max: number): string { return s.length <= max ? s : `${s.slice(0, max)}\n…[description truncated]`; } +function boundedError(error: string): string { + return clamp(error.replace(/[\u0000-\u001f\u007f]/g, " ").trim(), DESC_MAX_CHARS); +} + export interface AnthropicVisionProvider { providerName: string; provider: OcxProviderConfig; @@ -270,7 +274,7 @@ function renderDescription(out: { text: string; error?: string }): OcxTextConten return { type: "text", text: out.error - ? `[An image was attached but could not be processed: ${out.error}]` + ? `[An image was attached but could not be processed: ${boundedError(out.error)}]` : `[Image content — described by a vision model because you cannot see images directly:\n${clamp(out.text.trim(), DESC_MAX_CHARS)}]`, }; } diff --git a/tests/vision-cache.test.ts b/tests/vision-cache.test.ts index da8f4278f..7c0cdb827 100644 --- a/tests/vision-cache.test.ts +++ b/tests/vision-cache.test.ts @@ -283,6 +283,22 @@ describe("vision description cache and per-turn cap", () => { expect(textParts(request).join("\n")).toContain(expected); }); + test("bounds invalid media types and sidecar errors before inserting prompt text", async () => { + const invalidMime = parsed([{ type: "input_image", image_url: `data:${"x".repeat(10_000)},payload` }]); + await describeImagesInPlace(invalidMime, plan(), new Headers({ authorization: "Bearer test" })); + const invalidMarker = textParts(invalidMime).join("\n"); + expect(invalidMarker).toContain("invalid image media type"); + expect(invalidMarker).not.toContain("x".repeat(128)); + + globalThis.fetch = (async () => { throw new Error(`bad\u0000\n${"y".repeat(2_100)}`); }) as typeof fetch; + const failed = parsed([{ type: "input_image", image_url: DATA_A }]); + await describeImagesInPlace(failed, plan(), new Headers({ authorization: "Bearer test" })); + const failedMarker = textParts(failed).join("\n"); + expect(failedMarker).not.toContain("\u0000"); + expect(failedMarker).toContain("…[description truncated]"); + expect(failedMarker.length).toBeLessThan(2_100); + }); + test("cache hit returns the same clamped description without a sidecar call", async () => { let calls = 0; globalThis.fetch = (async () => { calls++; return openaiSse("y".repeat(2_100)); }) as typeof fetch;