根因(从 checkpoints 取到的那一轮 tool_call 实际 ARGS 逐字节核实):
模型交给工具的 image 参数是 "data:image/png;base64," —— 只有前缀、
载荷为空(全长 22 字符)。两个加载器早就会剥 data: 前缀,但把空载荷
解码成零字节后照样挂上合法 MIME 放行:
image-crop → 零字节进 image.Decode ⇒ image: unknown format
vision → DataURI("image/png", nil) 发给上游 ⇒ 400 unsupported image
(模型复述不出入参里那条 58583 字符的 data: URI,只抄下了前缀。)
- 新增 checkInlinePayload:data: URI 解码后做内容嗅探(复用既有裁决)
- 空载荷 / 缺 ;base64 段 / 缺逗号 / 非法 base64 ⇒ 明确拒绝
- 报错含「不是图片」+ 嗅到的类型 + 支持格式 + 十六进制物证 + 可行动提示,
且不回显正文或 base64
- 裸 base64、http(s) URL、工具 schema、安全策略一律未动
- 测试:新增 16 个用例;全树 61 包 ok;4 个变异(含停用嗅探 ⇒ 5 用例红)
99 lines
4.8 KiB
Go
99 lines
4.8 KiB
Go
package io
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// 本文件是 image-crop 的**入参内容嗅探 + 可行动报错**。此前 Execute 把源字节
|
|
// 直接丢给 image.Decode,失败时只报 `decode image: image: unknown format` ——
|
|
// 既不说是哪种内容,也不说本工具支持什么,模型无从自纠。
|
|
//
|
|
// 2026-10-07 18:32:51 的实测就是这个形状:模型传给 image-crop 的 image 是
|
|
// `data:image/png;base64,`(只有前缀、没有载荷),解码得到零字节 ⇒ 报
|
|
// unknown format;同一秒 vision 工具拿同一个入参,把空载荷当图片发给上游,
|
|
// 换回一句「You have uploaded an unsupported image」。两条报错都指不到真正的
|
|
// 毛病:参数里根本没有图片字节。现在这类输入在踩到解码器之前就被明确否决。
|
|
|
|
const (
|
|
// cropSniffBytes 是字节嗅探读取的前缀长度(http.DetectContentType 只看前 512 字节)。
|
|
cropSniffBytes = 512
|
|
// cropEvidenceBytes 是报错时作证给出的头部字节数。**绝不回显正文** ——
|
|
// 载荷可能很大,也可能含隐私内容,只给固定长度的头部十六进制。
|
|
cropEvidenceBytes = 16
|
|
// cropDecodableFormats 是本工具能本地解码的格式:标准库注册了 png / jpeg / gif
|
|
// 三个解码器(crop.go 的 import)。webp / bmp 没有标准库解码器,加解码器要引
|
|
// 新依赖,故如实报告「本地解不了」而不是含糊的 unknown format。
|
|
cropDecodableFormats = "png, jpeg, gif"
|
|
// cropUpstreamFormats 是视觉上游接受的格式(与上游白名单同口径),
|
|
// 「本地解不了」时给出这条替代路径。
|
|
cropUpstreamFormats = "webp, png, jpeg, gif"
|
|
// cropReuseHint 是「把非图片当图片传」时的可行动提示。
|
|
cropReuseHint = "to reuse an image from earlier in the conversation, pass its http(s) URL or local path instead of a data: URI whose base64 payload you cannot reproduce"
|
|
)
|
|
|
|
// checkCropSourceBytes 在解码之前嗅探源字节,返回嗅探到的媒体类型(供解码
|
|
// 失败时作证)。它不是 image.Decode 判断的复述,而是**换一种失败方式**:
|
|
// 不可能解出图的输入在这里得到「这不是图片 / 这个格式本地解不了」的明确报错。
|
|
func checkCropSourceBytes(data []byte) (string, error) {
|
|
if len(data) == 0 {
|
|
return "", fmt.Errorf("io: image-crop: source has no bytes, so it is not an image (empty payload); "+
|
|
"supported formats: %s; hint: %s", cropDecodableFormats, cropReuseHint)
|
|
}
|
|
sniffed := cropSniffMIME(data)
|
|
switch {
|
|
case cropDecodableMIME(sniffed):
|
|
return sniffed, nil
|
|
case strings.HasPrefix(sniffed, "image/"):
|
|
// 嗅探到图像签名,但本地没有它的解码器(webp / bmp / avif…)。
|
|
return sniffed, fmt.Errorf("io: image-crop: cannot decode %s locally: this tool decodes %s "+
|
|
"and the vision upstream accepts %s; convert the image to one of those formats first",
|
|
sniffed, cropDecodableFormats, cropUpstreamFormats)
|
|
case sniffed != "" && isTextContentType(sniffed):
|
|
// 复用的文本判据(util.go 的 isTextContentType):HTML/PROXY 错误页、
|
|
// JSON、SVG、纯文本都落在这里 —— 它们最常来自「把网页地址当图片直链」。
|
|
return sniffed, fmt.Errorf("io: image-crop: source is not an image: the bytes look like %s; "+
|
|
"supported formats: %s; leading bytes: %s; hint: %s",
|
|
sniffed, cropDecodableFormats, hexHead(data), cropReuseHint)
|
|
default:
|
|
return sniffed, fmt.Errorf("io: image-crop: source is not an image: unrecognised content (%s); "+
|
|
"supported formats: %s; leading bytes: %s; hint: %s",
|
|
sniffed, cropDecodableFormats, hexHead(data), cropReuseHint)
|
|
}
|
|
}
|
|
|
|
// cropSniffMIME 按真实字节嗅探媒体类型,归一化成无参数的小写形式。它是对
|
|
// 声明的否决权:扩展名、Content-Type 与 data: 前缀都可能骗人,字节不会。
|
|
// 判不出格式时 http.DetectContentType 返回 application/octet-stream。
|
|
func cropSniffMIME(data []byte) string {
|
|
if len(data) > cropSniffBytes {
|
|
data = data[:cropSniffBytes]
|
|
}
|
|
mt := http.DetectContentType(data)
|
|
return strings.ToLower(strings.TrimSpace(strings.Split(mt, ";")[0]))
|
|
}
|
|
|
|
// cropDecodableMIME 报告 mime 是否是本工具能本地解码的格式。
|
|
func cropDecodableMIME(mime string) bool {
|
|
switch mime {
|
|
case "image/png", "image/jpeg", "image/gif":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// hexHead 把载荷开头 cropEvidenceBytes 个字节渲染成十六进制,作为「它到底像
|
|
// 什么」的物证。只给固定的头部长度:错误信息里不得出现图片正文或 base64 载荷。
|
|
func hexHead(data []byte) string {
|
|
if len(data) > cropEvidenceBytes {
|
|
data = data[:cropEvidenceBytes]
|
|
}
|
|
parts := make([]string, 0, len(data))
|
|
for _, b := range data {
|
|
parts = append(parts, fmt.Sprintf("%02x", b))
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|