package file import ( "context" "fmt" "os" "path/filepath" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/plugin" ) // Plugin provides sandboxed file read/write inside the configured data directory. type Plugin struct { dataDir string } func New() *Plugin { return &Plugin{} } func (p *Plugin) Name() string { return "file" } func (p *Plugin) Version() string { return "1.0.0" } func (p *Plugin) Description() string { return "文件操作" } func (p *Plugin) Close() error { return nil } func (p *Plugin) Enabled() bool { return p.dataDir != "" } func (p *Plugin) Init(_ context.Context, cfg plugin.PluginConfig) error { p.dataDir = cfg.DataDir if p.dataDir == "" { p.dataDir = "data/files" } return os.MkdirAll(p.dataDir, 0o755) } func (p *Plugin) Tools() []plugin.ToolDefinition { return []plugin.ToolDefinition{ { Name: "file_read", Description: "Read a text file from the data directory.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]string{"type": "string", "description": "Relative path inside the data directory"}, }, "required": []string{"path"}, }, }, { Name: "file_write", Description: "Write text to a file in the data directory (overwrites).", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]string{"type": "string", "description": "Relative path inside the data directory"}, "content": map[string]string{"type": "string", "description": "Text content to write"}, }, "required": []string{"path", "content"}, }, }, { Name: "file_list", Description: "List files in the data directory (or a subdirectory).", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "path": map[string]string{"type": "string", "description": "Relative directory path (default: root)"}, }, }, }, } } func (p *Plugin) Execute(_ context.Context, toolName string, params map[string]any) (string, error) { rel, _ := params["path"].(string) path, err := p.resolve(rel) if err != nil { return "", err } switch toolName { case "file_read": data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("read: %w", err) } text := string(data) if runes := []rune(text); len(runes) > 4000 { text = string(runes[:4000]) + "\n…(文件过长已截断)" } return text, nil case "file_write": content, _ := params["content"].(string) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return "", err } if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return "", fmt.Errorf("write: %w", err) } return fmt.Sprintf("已写入 %s(%d 字节)", rel, len(content)), nil case "file_list": entries, err := os.ReadDir(path) if err != nil { return "", fmt.Errorf("list: %w", err) } if len(entries) == 0 { return "(空目录)", nil } var sb strings.Builder for _, e := range entries { name := e.Name() if e.IsDir() { name += "/" } sb.WriteString(name + "\n") } return sb.String(), nil } return "", fmt.Errorf("unknown tool %q", toolName) } // resolve maps a relative path into the sandbox and rejects escapes. func (p *Plugin) resolve(rel string) (string, error) { clean := filepath.Clean("/" + rel) // force relative interpretation full := filepath.Join(p.dataDir, clean) root, _ := filepath.Abs(p.dataDir) abs, _ := filepath.Abs(full) if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) { return "", fmt.Errorf("path escapes data directory") } return full, nil }