183 lines
4 KiB
Go
183 lines
4 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/crypto/byteclaw/internal/llm"
|
|
)
|
|
|
|
// Workspace confines file access to a root directory using os.Root
|
|
// (Go 1.24+), which rejects "..", absolute paths and symlink escapes.
|
|
type Workspace struct {
|
|
Dir string
|
|
MaxBytes int64
|
|
}
|
|
|
|
func (w Workspace) open() (*os.Root, error) {
|
|
if err := os.MkdirAll(w.Dir, 0o750); err != nil {
|
|
return nil, err
|
|
}
|
|
return os.OpenRoot(w.Dir)
|
|
}
|
|
|
|
func cleanRel(p string) (string, error) {
|
|
if p == "" {
|
|
p = "."
|
|
}
|
|
if filepath.IsAbs(p) {
|
|
return "", errors.New("path must be relative to the workspace")
|
|
}
|
|
return filepath.Clean(p), nil
|
|
}
|
|
|
|
type pathArgs struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
// ---- file_read
|
|
|
|
type FileRead struct{ Workspace }
|
|
|
|
func (FileRead) Spec() llm.ToolSpec {
|
|
return llm.ToolSpec{Name: "file_read", Description: "Read a text file inside the workspace.",
|
|
Parameters: obj(map[string]any{"path": map[string]any{"type": "string"}}, "path")}
|
|
}
|
|
|
|
func (f FileRead) Call(_ context.Context, raw json.RawMessage) (string, error) {
|
|
var a pathArgs
|
|
if err := json.Unmarshal(raw, &a); err != nil {
|
|
return "", err
|
|
}
|
|
p, err := cleanRel(a.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
root, err := f.open()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer root.Close()
|
|
fh, err := root.Open(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer fh.Close()
|
|
b, err := io.ReadAll(io.LimitReader(fh, f.MaxBytes+1))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if int64(len(b)) > f.MaxBytes {
|
|
return string(b[:f.MaxBytes]) + "\n[truncated]", nil
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// ---- file_write
|
|
|
|
type FileWrite struct{ Workspace }
|
|
|
|
func (FileWrite) Spec() llm.ToolSpec {
|
|
return llm.ToolSpec{Name: "file_write", Description: "Create or overwrite a text file inside the workspace.",
|
|
Parameters: obj(map[string]any{"path": map[string]any{"type": "string"}, "content": map[string]any{"type": "string"}}, "path", "content")}
|
|
}
|
|
|
|
func (f FileWrite) Call(_ context.Context, raw json.RawMessage) (string, error) {
|
|
var a pathArgs
|
|
if err := json.Unmarshal(raw, &a); err != nil {
|
|
return "", err
|
|
}
|
|
if int64(len(a.Content)) > f.MaxBytes {
|
|
return "", fmt.Errorf("content exceeds %d bytes", f.MaxBytes)
|
|
}
|
|
p, err := cleanRel(a.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if p == "." {
|
|
return "", errors.New("path required")
|
|
}
|
|
root, err := f.open()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer root.Close()
|
|
if dir := filepath.Dir(p); dir != "." {
|
|
if err := mkdirAll(root, dir); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
fh, err := root.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o640)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer fh.Close()
|
|
if _, err := fh.WriteString(a.Content); err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("wrote %d bytes to %s", len(a.Content), p), nil
|
|
}
|
|
|
|
func mkdirAll(root *os.Root, dir string) error {
|
|
cur := ""
|
|
for _, part := range strings.Split(filepath.ToSlash(dir), "/") {
|
|
cur = filepath.Join(cur, part)
|
|
if err := root.Mkdir(cur, 0o750); err != nil && !errors.Is(err, os.ErrExist) {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- file_list
|
|
|
|
type FileList struct{ Workspace }
|
|
|
|
func (FileList) Spec() llm.ToolSpec {
|
|
return llm.ToolSpec{Name: "file_list", Description: "List entries of a directory inside the workspace (default: root).",
|
|
Parameters: obj(map[string]any{"path": map[string]any{"type": "string"}})}
|
|
}
|
|
|
|
func (f FileList) Call(_ context.Context, raw json.RawMessage) (string, error) {
|
|
var a pathArgs
|
|
if err := json.Unmarshal(raw, &a); err != nil {
|
|
return "", err
|
|
}
|
|
p, err := cleanRel(a.Path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
root, err := f.open()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer root.Close()
|
|
d, err := root.Open(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer d.Close()
|
|
ents, err := d.ReadDir(500)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return "", err
|
|
}
|
|
var sb strings.Builder
|
|
for _, e := range ents {
|
|
name := e.Name()
|
|
if e.IsDir() {
|
|
name += "/"
|
|
}
|
|
sb.WriteString(name + "\n")
|
|
}
|
|
if sb.Len() == 0 {
|
|
return "(empty)", nil
|
|
}
|
|
return sb.String(), nil
|
|
}
|