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) {
|
||||
|
||||
Reference in new issue
Block a user