fix(multimodal): 视觉工具的空/畸形 data: URI 明确拒绝 + 入参嗅探与可行动报错
根因(从 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 用例红)
This commit is contained in:
1 parent
da1d23dd23
commit
789e3af74e
8 files changed
+636
-24
No files matched your search
@@ -104,9 +104,14 @@ func (c *cropTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sniffed, err := checkCropSourceBytes(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: image-crop: decode image: %w", err)
|
||||
return nil, fmt.Errorf("io: image-crop: decode image: %w: the bytes look like %s but could not be decoded "+
|
||||
"— the payload is probably truncated or corrupt", err, sniffed)
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
if err := checkRegion(region, bounds); err != nil {
|
||||
@@ -232,12 +237,23 @@ func (c *cropTool) load(ctx context.Context, src string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// decodeCropDataURI decodes a "data:<mime>;base64,<payload>" source.
|
||||
//
|
||||
// 空载荷是**明确拒绝**而不是交给解码器:`data:image/png;base64,` 这种「只有前缀、
|
||||
// 没有载荷」的入参实测出现过(模型复述不出几万字符的 base64,只把前缀抄了下来),
|
||||
// 旧路径把它解成零字节再报 `image: unknown format`,指不到真正的毛病。前缀本身
|
||||
// 不含图片正文,故可以安全回显(按 cropEvidenceBytes 截断,防止畸形长前缀灌进日志)。
|
||||
func decodeCropDataURI(src string) ([]byte, error) {
|
||||
comma := strings.IndexByte(src, ',')
|
||||
if comma < 0 || !strings.Contains(src[:comma], ";base64") {
|
||||
return nil, fmt.Errorf("io: image-crop: unsupported data URI (expected base64)")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(src[comma+1:])
|
||||
payload := src[comma+1:]
|
||||
if payload == "" {
|
||||
return nil, fmt.Errorf("io: image-crop: data URI %q carries no payload (the base64 part is empty); "+
|
||||
"pass the actual image bytes, or pass its http(s) URL / local path instead of retyping a data: URI",
|
||||
truncateHead(src[:comma+1], cropEvidenceBytes))
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: image-crop: decode data URI: %w", err)
|
||||
}
|
||||
@@ -247,6 +263,15 @@ func decodeCropDataURI(src string) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// truncateHead 把串截到至多 n 个字符,用于把**有限的、受控的**上下文写进错误
|
||||
// 文案(如 data: 前缀);图片正文与 base64 载荷一律不进错误信息。
|
||||
func truncateHead(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// readFile reads a path through the file tool's sandbox so cropping can never
|
||||
// reach a file the file tool could not read (裁决 13.4.4:文件沙箱唯一实现处).
|
||||
func (c *cropTool) readFile(p string) ([]byte, error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -179,6 +180,148 @@ func TestCropTool_RejectsUnsupportedDataURI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// gradientJPEG 与 gradientPNG 同构,供「data: URI 带真 JPEG 载荷」的用例使用。
|
||||
// JPEG 有损,故用例只钉裁切尺寸,不逐像素比对。
|
||||
func gradientJPEG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x * 2), G: uint8(y * 2), A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatalf("jpeg.Encode: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// cropArgs 拼一组 image-crop 的参数,省得每个用例重抄区域坐标。
|
||||
func cropArgs(source string) map[string]any {
|
||||
return map[string]any{"image": source, "x": 2, "y": 2, "width": 4, "height": 3}
|
||||
}
|
||||
|
||||
// TestCropTool_DataURIRealImages 钉住主用例:带着**完整 base64 载荷**的
|
||||
// data: URI(png 与 jpeg)都必须照常裁切 —— 也就是 2026-10-07 那次失败里
|
||||
// 「本该成立的形状」。
|
||||
func TestCropTool_DataURIRealImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
sources := map[string]string{
|
||||
"png": "data:image/png;base64," + base64.StdEncoding.EncodeToString(gradientPNG(t, 20, 20)),
|
||||
"jpeg": "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(gradientJPEG(t, 20, 20)),
|
||||
}
|
||||
for name, source := range sources {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
res, err := tool.Execute(context.Background(), cropArgs(source))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute(%s data URI): %v", name, err)
|
||||
}
|
||||
if img := decodeCropResult(t, res); img.Bounds().Dx() != 4 || img.Bounds().Dy() != 3 {
|
||||
t.Fatalf("crop size = %dx%d, want 4x3", img.Bounds().Dx(), img.Bounds().Dy())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_RejectsEmptyDataURIPayload 是本缺陷的回归用例(2026-10-07
|
||||
// 18:32:51 实测):模型传的是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
|
||||
// 旧路径把它解成零字节再报 `image: unknown format`,指不到真正的毛病。
|
||||
// 现在必须在本地说清病因,并且**不许**再出现那句 unknown format。
|
||||
func TestCropTool_RejectsEmptyDataURIPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
_, err := tool.Execute(context.Background(), cropArgs("data:image/png;base64,"))
|
||||
if err == nil {
|
||||
t.Fatal("空载荷的 data: URI 被接受,want 报错")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "unknown format") {
|
||||
t.Fatalf("错误文案退回旧形状(unknown format):%v", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "no payload") {
|
||||
t.Errorf("错误文案没点出「载荷为空」:%v", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "http(s) URL") {
|
||||
t.Errorf("错误文案没给可行动提示:%v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_RejectsNonImageAsNotAnImage 钉住「这不是图片」:把 HTML 当图片
|
||||
// 传(无论走 data: URI 还是 URL)都要报明确错误 —— 说清嗅探到的类型、
|
||||
// 支持的格式与可行动提示,且**不得回显正文**。
|
||||
func TestCropTool_RejectsNonImageAsNotAnImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
html := []byte("<!DOCTYPE html><html><body>502 Bad Gateway</body></html>")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(html)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
tool, _ := newCropToolForTest(t, WithAllowedHosts(serverHost(srv)))
|
||||
sources := map[string]string{
|
||||
"data uri": "data:text/html;base64," + base64.StdEncoding.EncodeToString(html),
|
||||
"url": srv.URL + "/page.html",
|
||||
}
|
||||
for name, source := range sources {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := tool.Execute(context.Background(), cropArgs(source))
|
||||
if err == nil {
|
||||
t.Fatal("HTML 被当成图片接受,want 报错")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "502 Bad Gateway") || strings.Contains(msg, "<!DOCTYPE") {
|
||||
t.Fatalf("错误文案回显了正文:%v", msg)
|
||||
}
|
||||
for _, want := range []string{"not an image", "text/html", "png", "jpeg", "gif"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("错误文案缺少 %q:%v", want, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_MalformedDataURI 钉住畸形入参不 panic、报可行动错误。
|
||||
func TestCropTool_MalformedDataURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
cases := map[string]string{
|
||||
"no comma": "data:image/jpeg;base64",
|
||||
"no base64": "data:image/png,plaintext",
|
||||
"illegal base64": "data:image/png;base64,!!!!",
|
||||
"empty payload": "data:image/jpeg;base64,",
|
||||
}
|
||||
for name, source := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := tool.Execute(context.Background(), cropArgs(source)); err == nil {
|
||||
t.Fatalf("%q 被接受,want 报错", source)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_BareBase64StaysAPath 钉住既有分类不变:裸 base64 **不是**本工具
|
||||
// 支持的输入(cropSourceKindOf 只认 data: / http(s) / 路径),它照旧走文件路径那条
|
||||
// 分支并被沙箱拒绝 —— 这次改动不碰它。
|
||||
func TestCropTool_BareBase64StaysAPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
bare := base64.StdEncoding.EncodeToString(gradientPNG(t, 8, 8))
|
||||
_, err := tool.Execute(context.Background(), cropArgs(bare))
|
||||
if err == nil {
|
||||
t.Fatal("裸 base64 被当成图片接受,want 走路径分支并被拒")
|
||||
}
|
||||
if strings.Contains(err.Error(), "not an image") {
|
||||
t.Fatalf("裸 base64 的失败形状变了(应仍是路径/沙箱报错):%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_RegisteredInSet pins that the tool ships with the default set and
|
||||
// advertises a usable schema (the agent only sees it if it is registered).
|
||||
func TestCropTool_RegisteredInSet(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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, " ")
|
||||
}
|
||||
Reference in new issue
Block a user