diff --git a/internal/config/vision.go b/internal/config/vision.go index c11c8c8a..1052d3b9 100644 --- a/internal/config/vision.go +++ b/internal/config/vision.go @@ -15,8 +15,7 @@ type VisionConfig struct { JPEGQuality int `yaml:"jpeg_quality,omitempty" json:"jpeg_quality,omitempty"` MaxPayloadBytes int64 `yaml:"max_payload_bytes,omitempty" json:"max_payload_bytes,omitempty"` SkipPreprocessBelowBytes int64 `yaml:"skip_preprocess_below_bytes,omitempty" json:"skip_preprocess_below_bytes,omitempty"` // 0=始终压缩;默认 2MB 且长边已<=max_dimension 时原图直传 - Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` // low | high | auto - AllowedRoots []string `yaml:"allowed_roots,omitempty" json:"allowed_roots,omitempty"` + Detail string `yaml:"detail,omitempty" json:"detail,omitempty"` // low | high | auto } func (v VisionConfig) TimeoutSecondsEffective() int { diff --git a/internal/handler/config.go b/internal/handler/config.go index 7944a61b..8f9cf5c1 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -1548,9 +1548,6 @@ func updateVisionConfig(doc *yaml.Node, cfg config.VisionConfig) { if strings.TrimSpace(cfg.Detail) != "" { setStringInMap(visionNode, "detail", cfg.Detail) } - if len(cfg.AllowedRoots) > 0 { - setStringSliceInMap(visionNode, "allowed_roots", cfg.AllowedRoots) - } } func updateOpenAIConfig(doc *yaml.Node, cfg config.OpenAIConfig) { diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index 428248a5..d0c4dc71 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -809,8 +809,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "jpeg_quality": map[string]interface{}{"type": "integer", "description": "JPEG 质量 60-100"}, "max_payload_bytes": map[string]interface{}{"type": "integer", "description": "送 API 体积上限(字节)"}, "skip_preprocess_below_bytes": map[string]interface{}{"type": "integer", "description": "低于该字节且尺寸合规时可原图直传;0=始终压缩"}, - "detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"}, - "allowed_roots": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "额外允许读取的绝对路径根"}, + "detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"}, }, }, "AnalyzeImageToolCall": map[string]interface{}{ @@ -819,7 +818,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "properties": map[string]interface{}{ "path": map[string]interface{}{ "type": "string", - "description": "图片路径(cwd、chat_uploads、result_storage_dir 或 allowed_roots 下)", + "description": "图片绝对路径或相对于进程工作目录的路径", }, "question": map[string]interface{}{ "type": "string", diff --git a/internal/vision/path.go b/internal/vision/path.go index 439921ac..3d9756ed 100644 --- a/internal/vision/path.go +++ b/internal/vision/path.go @@ -7,35 +7,26 @@ import ( "strings" ) -const chatUploadsDirName = "chat_uploads" - var allowedImageExt = map[string]struct{}{ ".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {}, ".gif": {}, ".bmp": {}, ".tif": {}, ".tiff": {}, } -// PathOptions 图片路径白名单根目录。 -type PathOptions struct { - CWD string - ResultStorageDir string // 相对 CWD,如 tmp - ExtraRoots []string // vision.allowed_roots 绝对路径 -} - -// ResolveImagePath 解析并校验可读图片路径(防穿越、symlink 逃逸)。 -func ResolveImagePath(path string, opt PathOptions) (string, error) { +// ResolveImagePath 解析并校验可读图片路径(支持任意目录;仍校验扩展名与常规文件)。 +func ResolveImagePath(path string, cwd string) (string, error) { p := strings.TrimSpace(path) if p == "" { return "", fmt.Errorf("path is empty") } - cwd := strings.TrimSpace(opt.CWD) - if cwd == "" { + cwdTrim := strings.TrimSpace(cwd) + if cwdTrim == "" { var err error - cwd, err = os.Getwd() + cwdTrim, err = os.Getwd() if err != nil { return "", fmt.Errorf("getwd: %w", err) } } - cwdAbs, err := filepath.Abs(filepath.Clean(cwd)) + cwdAbs, err := filepath.Abs(filepath.Clean(cwdTrim)) if err != nil { return "", err } @@ -46,22 +37,16 @@ func ResolveImagePath(path string, opt PathOptions) (string, error) { } else { candidate = filepath.Clean(filepath.Join(cwdAbs, p)) } - candidate = normalizeAbsPath(candidate) - if candidate == "" { + resolved := normalizeAbsPath(candidate) + if resolved == "" { return "", fmt.Errorf("invalid path") } - ext := strings.ToLower(filepath.Ext(candidate)) + ext := strings.ToLower(filepath.Ext(resolved)) if _, ok := allowedImageExt[ext]; !ok { return "", fmt.Errorf("unsupported image extension %q", ext) } - roots := buildAllowedRoots(cwdAbs, opt) - resolved, err := evalUnderAllowedRoots(candidate, roots) - if err != nil { - return "", err - } - st, err := os.Stat(resolved) if err != nil { return "", fmt.Errorf("stat: %w", err) @@ -85,58 +70,3 @@ func normalizeAbsPath(p string) string { } return abs } - -func buildAllowedRoots(cwdAbs string, opt PathOptions) []string { - seen := make(map[string]struct{}) - var roots []string - add := func(r string) { - r = strings.TrimSpace(r) - if r == "" { - return - } - abs := normalizeAbsPath(r) - if abs == "" { - return - } - if _, ok := seen[abs]; ok { - return - } - seen[abs] = struct{}{} - roots = append(roots, abs) - } - add(cwdAbs) - add(filepath.Join(cwdAbs, chatUploadsDirName)) - rs := strings.TrimSpace(opt.ResultStorageDir) - if rs == "" { - rs = "tmp" - } - if filepath.IsAbs(rs) { - add(rs) - } else { - add(filepath.Join(cwdAbs, rs)) - } - for _, r := range opt.ExtraRoots { - add(r) - } - return roots -} - -func evalUnderAllowedRoots(candidate string, roots []string) (string, error) { - check := normalizeAbsPath(candidate) - for _, root := range roots { - if isUnderRoot(check, root) { - return candidate, nil - } - } - return "", fmt.Errorf("path %q is outside allowed directories", candidate) -} - -func isUnderRoot(path, root string) bool { - path = filepath.Clean(path) - root = filepath.Clean(root) - if path == root { - return true - } - sep := string(filepath.Separator) - return strings.HasPrefix(path, root+sep) -} diff --git a/internal/vision/path_test.go b/internal/vision/path_test.go index 390112ef..b38206bf 100644 --- a/internal/vision/path_test.go +++ b/internal/vision/path_test.go @@ -12,7 +12,7 @@ func TestResolveImagePath_underCWD(t *testing.T) { if err := os.WriteFile(img, []byte{0x89, 0x50, 0x4e, 0x47}, 0o644); err != nil { t.Fatal(err) } - got, err := ResolveImagePath(img, PathOptions{CWD: dir, ResultStorageDir: "tmp"}) + got, err := ResolveImagePath(img, dir) if err != nil { t.Fatal(err) } @@ -22,11 +22,20 @@ func TestResolveImagePath_underCWD(t *testing.T) { } } -func TestResolveImagePath_rejectsTraversal(t *testing.T) { +func TestResolveImagePath_absoluteOutsideCWD(t *testing.T) { dir := t.TempDir() - _, err := ResolveImagePath("../../../etc/passwd", PathOptions{CWD: dir}) - if err == nil { - t.Fatal("expected error for path outside roots") + cwd := t.TempDir() + img := filepath.Join(dir, "remote.png") + if err := os.WriteFile(img, []byte{0x89, 0x50, 0x4e, 0x47}, 0o644); err != nil { + t.Fatal(err) + } + got, err := ResolveImagePath(img, cwd) + if err != nil { + t.Fatalf("expected absolute path outside cwd to be allowed: %v", err) + } + want := normalizeAbsPath(img) + if got != want { + t.Fatalf("got %q want %q", got, want) } } @@ -36,7 +45,7 @@ func TestResolveImagePath_rejectsNonImageExt(t *testing.T) { if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { t.Fatal(err) } - _, err := ResolveImagePath(f, PathOptions{CWD: dir}) + _, err := ResolveImagePath(f, dir) if err == nil { t.Fatal("expected error for non-image extension") } diff --git a/internal/vision/tool.go b/internal/vision/tool.go index ad5780ea..db1c2bc6 100644 --- a/internal/vision/tool.go +++ b/internal/vision/tool.go @@ -33,11 +33,6 @@ func RegisterAnalyzeImageTool(mcpServer *mcp.Server, cfg *config.Config, logger return } - pathOpt := PathOptions{ - CWD: cwd, - ResultStorageDir: cfg.Agent.ResultStorageDir, - ExtraRoots: cfg.Vision.AllowedRoots, - } preOpt := PreprocessOptions{ MaxImageBytes: cfg.Vision.MaxImageBytesEffective(), MaxDimension: cfg.Vision.MaxDimensionEffective(), @@ -73,7 +68,7 @@ func RegisterAnalyzeImageTool(mcpServer *mcp.Server, cfg *config.Config, logger path, _ := args["path"].(string) question, _ := args["question"].(string) - abs, err := ResolveImagePath(path, pathOpt) + abs, err := ResolveImagePath(path, cwd) if err != nil { return textResult(fmt.Sprintf("路径校验失败: %v", err), true), nil }