feat: add workspace-isolated toolhost runtime and capability-gap skill loop
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
23
internal/tools/shelltool/shelltool_test.go
Normal file
23
internal/tools/shelltool/shelltool_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user