feat: add workspace-isolated toolhost runtime and capability-gap skill loop
This commit is contained in:
@@ -12,10 +12,11 @@ import (
|
||||
|
||||
type Tool struct {
|
||||
allowedDirs []string
|
||||
maxOutputChars int
|
||||
log *logger.Logger
|
||||
}
|
||||
|
||||
func New(allowedDirs []string, log *logger.Logger) *Tool {
|
||||
func New(allowedDirs []string, maxOutputChars int, log *logger.Logger) *Tool {
|
||||
normalized := make([]string, 0, len(allowedDirs))
|
||||
for _, dir := range allowedDirs {
|
||||
abs, err := filepath.Abs(strings.TrimSpace(dir))
|
||||
@@ -23,16 +24,19 @@ func New(allowedDirs []string, log *logger.Logger) *Tool {
|
||||
normalized = append(normalized, filepath.Clean(abs))
|
||||
}
|
||||
}
|
||||
if log != nil {
|
||||
log.Infof("file tool initialized allowed_dirs=%d", len(normalized))
|
||||
if maxOutputChars <= 0 {
|
||||
maxOutputChars = 4000
|
||||
}
|
||||
return &Tool{allowedDirs: normalized, log: log}
|
||||
if log != nil {
|
||||
log.Infof("file tool initialized allowed_dirs=%d max_output_chars=%d", len(normalized), maxOutputChars)
|
||||
}
|
||||
return &Tool{allowedDirs: normalized, maxOutputChars: maxOutputChars, 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>"
|
||||
return "File operations with command format: read <path> | list <path> | write <path>\\n<content>"
|
||||
}
|
||||
|
||||
func (t *Tool) Call(_ context.Context, input string) (string, error) {
|
||||
@@ -49,6 +53,16 @@ func (t *Tool) Call(_ context.Context, input string) (string, error) {
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil {
|
||||
if t.log != nil {
|
||||
t.log.Errorf("file read stat failed path=%s err=%v", resolved, err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", fmt.Errorf("PATH_IS_DIRECTORY: %s (use 'list <path>' first)", resolved)
|
||||
}
|
||||
b, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
if t.log != nil {
|
||||
@@ -59,7 +73,49 @@ func (t *Tool) Call(_ context.Context, input string) (string, error) {
|
||||
if t.log != nil {
|
||||
t.log.Infof("file read success path=%s bytes=%d", resolved, len(b))
|
||||
}
|
||||
return string(b), nil
|
||||
out := string(b)
|
||||
if len(out) > t.maxOutputChars {
|
||||
out = out[:t.maxOutputChars]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(input, "list ") {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(input, "list "))
|
||||
resolved, err := t.resolveAllowed(path)
|
||||
if err != nil {
|
||||
if t.log != nil {
|
||||
t.log.Warnf("file list denied path=%s err=%v", path, err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
entries, err := os.ReadDir(resolved)
|
||||
if err != nil {
|
||||
if t.log != nil {
|
||||
t.log.Errorf("file list failed path=%s err=%v", resolved, err)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
b := strings.Builder{}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
name += "/"
|
||||
}
|
||||
b.WriteString(name)
|
||||
b.WriteString("\n")
|
||||
if b.Len() >= t.maxOutputChars {
|
||||
break
|
||||
}
|
||||
}
|
||||
out := strings.TrimSpace(b.String())
|
||||
if out == "" {
|
||||
return "(empty)", nil
|
||||
}
|
||||
if len(out) > t.maxOutputChars {
|
||||
out = out[:t.maxOutputChars]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(input, "write ") {
|
||||
@@ -97,9 +153,18 @@ func (t *Tool) Call(_ context.Context, input string) (string, error) {
|
||||
}
|
||||
|
||||
func (t *Tool) resolveAllowed(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
base := strings.TrimSpace(os.Getenv("AGENT_WORKSPACE_DIR"))
|
||||
var abs string
|
||||
var err error
|
||||
if filepath.IsAbs(path) {
|
||||
abs = path
|
||||
} else if base != "" {
|
||||
abs = filepath.Join(base, path)
|
||||
} else {
|
||||
abs, err = filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
abs = filepath.Clean(abs)
|
||||
for _, allowed := range t.allowedDirs {
|
||||
|
||||
66
internal/tools/filetool/filetool_test.go
Normal file
66
internal/tools/filetool/filetool_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package filetool
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadDeniedOutsideAllowedDir(t *testing.T) {
|
||||
allowed := t.TempDir()
|
||||
tool := New([]string{allowed}, 4000, nil)
|
||||
_, err := tool.Call(context.Background(), "read ../outside.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected path denied error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAndReadInsideAllowedDir(t *testing.T) {
|
||||
allowed := t.TempDir()
|
||||
tool := New([]string{allowed}, 4000, nil)
|
||||
path := filepath.Join(allowed, "a.txt")
|
||||
|
||||
_, err := tool.Call(context.Background(), "write "+path+"\nhello")
|
||||
if err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
out, err := tool.Call(context.Background(), "read "+path)
|
||||
if err != nil {
|
||||
t.Fatalf("read error: %v", err)
|
||||
}
|
||||
if out != "hello" {
|
||||
t.Fatalf("unexpected read output: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDirectoryReturnsStructuredError(t *testing.T) {
|
||||
allowed := t.TempDir()
|
||||
tool := New([]string{allowed}, 4000, nil)
|
||||
|
||||
_, err := tool.Call(context.Background(), "read "+allowed)
|
||||
if err == nil {
|
||||
t.Fatal("expected directory read error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "PATH_IS_DIRECTORY") {
|
||||
t.Fatalf("expected PATH_IS_DIRECTORY, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectory(t *testing.T) {
|
||||
allowed := t.TempDir()
|
||||
tool := New([]string{allowed}, 4000, nil)
|
||||
path := filepath.Join(allowed, "x.txt")
|
||||
|
||||
_, err := tool.Call(context.Background(), "write "+path+"\nhello")
|
||||
if err != nil {
|
||||
t.Fatalf("write error: %v", err)
|
||||
}
|
||||
out, err := tool.Call(context.Background(), "list "+allowed)
|
||||
if err != nil {
|
||||
t.Fatalf("list error: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "x.txt") {
|
||||
t.Fatalf("expected x.txt in list output, got: %q", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user