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:
AskaEth committed 2026-10-07 18:50:45 +08:00
1 parent da1d23dd23
commit 789e3af74e
8 files changed
+636 -24

No files matched your search

+27 -2
View File
@@ -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) {