package tools import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "github.com/crypto/openclaw/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 }