2026-02-21 23:01:39 +08:00
|
|
|
package filetool
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"laodingbot/internal/logger"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Tool struct {
|
|
|
|
|
allowedDirs []string
|
|
|
|
|
log *logger.Logger
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func New(allowedDirs []string, log *logger.Logger) *Tool {
|
|
|
|
|
normalized := make([]string, 0, len(allowedDirs))
|
|
|
|
|
for _, dir := range allowedDirs {
|
|
|
|
|
abs, err := filepath.Abs(strings.TrimSpace(dir))
|
|
|
|
|
if err == nil {
|
|
|
|
|
normalized = append(normalized, filepath.Clean(abs))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if log != nil {
|
|
|
|
|
log.Infof("file tool initialized allowed_dirs=%d", len(normalized))
|
|
|
|
|
}
|
|
|
|
|
return &Tool{allowedDirs: normalized, log: log}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *Tool) Name() string { return "file" }
|
|
|
|
|
|
|
|
|
|
func (t *Tool) Description() string {
|
|
|
|
|
return "File operations with command format: read <path> | write <path>\\n<content>"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *Tool) Call(_ context.Context, input string) (string, error) {
|
|
|
|
|
input = strings.TrimSpace(input)
|
|
|
|
|
if t.log != nil {
|
2026-02-21 23:29:27 +08:00
|
|
|
t.log.Infof("file tool call input_len=%d input=%q", len(input), input)
|
2026-02-21 23:01:39 +08:00
|
|
|
}
|
|
|
|
|
if strings.HasPrefix(input, "read ") {
|
|
|
|
|
path := strings.TrimSpace(strings.TrimPrefix(input, "read "))
|
|
|
|
|
resolved, err := t.resolveAllowed(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if t.log != nil {
|
|
|
|
|
t.log.Warnf("file read denied path=%s err=%v", path, err)
|
|
|
|
|
}
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
b, err := os.ReadFile(resolved)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if t.log != nil {
|
|
|
|
|
t.log.Errorf("file read failed path=%s err=%v", resolved, err)
|
|
|
|
|
}
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if t.log != nil {
|
|
|
|
|
t.log.Infof("file read success path=%s bytes=%d", resolved, len(b))
|
|
|
|
|
}
|
|
|
|
|
return string(b), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if strings.HasPrefix(input, "write ") {
|
|
|
|
|
parts := strings.SplitN(input, "\n", 2)
|
|
|
|
|
if len(parts) < 2 {
|
|
|
|
|
return "", fmt.Errorf("write requires content in second line")
|
|
|
|
|
}
|
|
|
|
|
path := strings.TrimSpace(strings.TrimPrefix(parts[0], "write "))
|
|
|
|
|
resolved, err := t.resolveAllowed(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if t.log != nil {
|
|
|
|
|
t.log.Warnf("file write denied path=%s err=%v", path, err)
|
|
|
|
|
}
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil {
|
|
|
|
|
if t.log != nil {
|
|
|
|
|
t.log.Errorf("file write mkdir failed path=%s err=%v", resolved, err)
|
|
|
|
|
}
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if err := os.WriteFile(resolved, []byte(parts[1]), 0o644); err != nil {
|
|
|
|
|
if t.log != nil {
|
|
|
|
|
t.log.Errorf("file write failed path=%s err=%v", resolved, err)
|
|
|
|
|
}
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
if t.log != nil {
|
|
|
|
|
t.log.Infof("file write success path=%s bytes=%d", resolved, len(parts[1]))
|
|
|
|
|
}
|
|
|
|
|
return "ok", nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return "", fmt.Errorf("unsupported file command")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (t *Tool) resolveAllowed(path string) (string, error) {
|
|
|
|
|
abs, err := filepath.Abs(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
abs = filepath.Clean(abs)
|
|
|
|
|
for _, allowed := range t.allowedDirs {
|
|
|
|
|
if strings.HasPrefix(abs, allowed+string(filepath.Separator)) || abs == allowed {
|
|
|
|
|
return abs, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return "", fmt.Errorf("path not allowed: %s", path)
|
|
|
|
|
}
|