feat: add workspace-isolated toolhost runtime and capability-gap skill loop

This commit is contained in:
2026-02-28 17:48:33 +08:00
parent ce9346e350
commit 7d6cf6b435
28 changed files with 2223 additions and 143 deletions

View File

@@ -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 {

View 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)
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
@@ -15,10 +16,11 @@ type Tool struct {
allowedCommands map[string]struct{}
workDir string
timeout time.Duration
maxOutputChars int
log *logger.Logger
}
func New(allowed []string, workDir string, timeout time.Duration, log *logger.Logger) *Tool {
func New(allowed []string, workDir string, timeout time.Duration, maxOutputChars int, log *logger.Logger) *Tool {
set := make(map[string]struct{}, len(allowed))
for _, c := range allowed {
cmd := strings.TrimSpace(c)
@@ -33,10 +35,13 @@ func New(allowed []string, workDir string, timeout time.Duration, log *logger.Lo
if timeout <= 0 {
timeout = 15 * time.Second
}
if log != nil {
log.Infof("shell tool initialized allowed_commands=%d work_dir=%s timeout=%s", len(set), absDir, timeout)
if maxOutputChars <= 0 {
maxOutputChars = 4000
}
return &Tool{allowedCommands: set, workDir: absDir, timeout: timeout, log: log}
if log != nil {
log.Infof("shell tool initialized allowed_commands=%d work_dir=%s timeout=%s max_output_chars=%d", len(set), absDir, timeout, maxOutputChars)
}
return &Tool{allowedCommands: set, workDir: absDir, timeout: timeout, maxOutputChars: maxOutputChars, log: log}
}
func (t *Tool) Name() string { return "shell" }
@@ -72,14 +77,21 @@ func (t *Tool) Call(ctx context.Context, input string) (string, error) {
cmd := exec.CommandContext(runCtx, base, parts[1:]...)
cmd.Dir = t.workDir
out, err := cmd.CombinedOutput()
outText := string(out)
if len(outText) > t.maxOutputChars {
outText = outText[:t.maxOutputChars]
}
if err != nil {
if t.log != nil {
t.log.Errorf("shell command failed command=%s full_command=%q err=%v output_bytes=%d output=%q", base, trimmed, err, len(out), string(out))
t.log.Errorf("shell command failed command=%s full_command=%q err=%v output_bytes=%d output=%q", base, trimmed, err, len(out), outText)
}
return string(out), err
if runtime.GOOS == "windows" && strings.Contains(strings.ToLower(err.Error()), "executable file not found") {
return outText, fmt.Errorf("command not executable in current windows environment: %s", base)
}
return outText, err
}
if t.log != nil {
t.log.Infof("shell command success command=%s full_command=%q output_bytes=%d output=%q", base, trimmed, len(out), string(out))
t.log.Infof("shell command success command=%s full_command=%q output_bytes=%d output=%q", base, trimmed, len(out), outText)
}
return string(out), nil
return outText, nil
}

View File

@@ -0,0 +1,23 @@
package shelltool
import (
"context"
"testing"
"time"
)
func TestCallRejectsEmptyCommand(t *testing.T) {
tool := New([]string{"echo"}, ".", time.Second, 4000, nil)
_, err := tool.Call(context.Background(), " ")
if err == nil {
t.Fatal("expected error for empty command")
}
}
func TestCallRejectsNonAllowlistedCommand(t *testing.T) {
tool := New([]string{"echo"}, ".", time.Second, 4000, nil)
_, err := tool.Call(context.Background(), "cat test.txt")
if err == nil {
t.Fatal("expected allowlist rejection")
}
}