Track tool call count, per-tool breakdown, and duration in ToolLoopResult,
propagate via bus Metadata, and format as "📋 scout-1 completed (3.2s, 5 tool calls)."
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1076 lines
30 KiB
Go
1076 lines
30 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestShellTool_Success verifies successful command execution
|
|
func TestShellTool_Success(t *testing.T) {
|
|
tool, err := NewExecTool("", false)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
args := map[string]any{
|
|
"command": "echo 'hello world'",
|
|
}
|
|
|
|
result := tool.Execute(ctx, args)
|
|
|
|
// Success should not be an error
|
|
if result.IsError {
|
|
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
|
}
|
|
|
|
// ForUser should contain command output
|
|
if !strings.Contains(result.ForUser, "hello world") {
|
|
t.Errorf("Expected ForUser to contain 'hello world', got: %s", result.ForUser)
|
|
}
|
|
|
|
// ForLLM should contain full output
|
|
if !strings.Contains(result.ForLLM, "hello world") {
|
|
t.Errorf("Expected ForLLM to contain 'hello world', got: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
// TestShellTool_Failure verifies failed command execution
|
|
func TestShellTool_Failure(t *testing.T) {
|
|
tool, err := NewExecTool("", false)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
args := map[string]any{
|
|
"command": "ls /nonexistent_directory_12345",
|
|
}
|
|
|
|
result := tool.Execute(ctx, args)
|
|
|
|
// Failure should be marked as error
|
|
if !result.IsError {
|
|
t.Errorf("Expected error for failed command, got IsError=false")
|
|
}
|
|
|
|
// ForUser should contain error information
|
|
if result.ForUser == "" {
|
|
t.Errorf("Expected ForUser to contain error info, got empty string")
|
|
}
|
|
|
|
// ForLLM should contain exit code or error
|
|
if !strings.Contains(result.ForLLM, "Exit code") && result.ForUser == "" {
|
|
t.Errorf("Expected ForLLM to contain exit code or error, got: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
// TestShellTool_Timeout verifies command timeout handling
|
|
func TestShellTool_Timeout(t *testing.T) {
|
|
tool, err := NewExecTool("", false)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
tool.SetTimeout(100 * time.Millisecond)
|
|
|
|
ctx := context.Background()
|
|
args := map[string]any{
|
|
"command": "sleep 10",
|
|
}
|
|
|
|
result := tool.Execute(ctx, args)
|
|
|
|
// Timeout should be marked as error
|
|
if !result.IsError {
|
|
t.Errorf("Expected error for timeout, got IsError=false")
|
|
}
|
|
|
|
// Should mention timeout
|
|
if !strings.Contains(result.ForLLM, "timed out") && !strings.Contains(result.ForUser, "timed out") {
|
|
t.Errorf("Expected timeout message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
|
}
|
|
}
|
|
|
|
// TestShellTool_WorkingDir verifies custom working directory
|
|
func TestShellTool_WorkingDir(t *testing.T) {
|
|
// Create temp directory
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.txt")
|
|
os.WriteFile(testFile, []byte("test content"), 0o644)
|
|
|
|
tool, err := NewExecTool("", false)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
args := map[string]any{
|
|
"command": "cat test.txt",
|
|
"working_dir": tmpDir,
|
|
}
|
|
|
|
result := tool.Execute(ctx, args)
|
|
|
|
if result.IsError {
|
|
t.Errorf("Expected success in custom working dir, got error: %s", result.ForLLM)
|
|
}
|
|
|
|
if !strings.Contains(result.ForUser, "test content") {
|
|
t.Errorf("Expected output from custom dir, got: %s", result.ForUser)
|
|
}
|
|
}
|
|
|
|
// TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands
|
|
func TestShellTool_DangerousCommand(t *testing.T) {
|
|
tool, err := NewExecTool("", false)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
args := map[string]any{
|
|
"command": "rm -rf /",
|
|
}
|
|
|
|
result := tool.Execute(ctx, args)
|
|
|
|
// Dangerous command should be blocked
|
|
if !result.IsError {
|
|
t.Errorf("Expected dangerous command to be blocked (IsError=true)")
|
|
}
|
|
|
|
if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") {
|
|
t.Errorf("Expected 'blocked' message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
|
}
|
|
}
|
|
|
|
// TestShellTool_MissingCommand verifies error handling for missing command
|
|
func TestShellTool_MissingCommand(t *testing.T) {
|
|
tool, err := NewExecTool("", false)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
args := map[string]any{}
|
|
|
|
result := tool.Execute(ctx, args)
|
|
|
|
// Should return error result
|
|
if !result.IsError {
|
|
t.Errorf("Expected error when command is missing")
|
|
}
|
|
}
|
|
|
|
// TestShellTool_StderrCapture verifies stderr is captured and included
|
|
func TestShellTool_StderrCapture(t *testing.T) {
|
|
tool, err := NewExecTool("", false)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
args := map[string]any{
|
|
"command": "sh -c 'echo stdout; echo stderr >&2'",
|
|
}
|
|
|
|
result := tool.Execute(ctx, args)
|
|
|
|
// Both stdout and stderr should be in output
|
|
if !strings.Contains(result.ForLLM, "stdout") {
|
|
t.Errorf("Expected stdout in output, got: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "stderr") {
|
|
t.Errorf("Expected stderr in output, got: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
// TestShellTool_OutputTruncation verifies long output is truncated
|
|
func TestShellTool_OutputTruncation(t *testing.T) {
|
|
tool, err := NewExecTool("", false)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
// Generate long output (>10000 chars)
|
|
args := map[string]any{
|
|
"command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000),
|
|
}
|
|
|
|
result := tool.Execute(ctx, args)
|
|
|
|
// Should have truncation message or be truncated
|
|
if len(result.ForLLM) > 15000 {
|
|
t.Errorf("Expected output to be truncated, got length: %d", len(result.ForLLM))
|
|
}
|
|
}
|
|
|
|
// TestShellTool_WorkingDir_OutsideWorkspace verifies that working_dir cannot escape the workspace directly
|
|
func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
|
|
root := t.TempDir()
|
|
workspace := filepath.Join(root, "workspace")
|
|
outsideDir := filepath.Join(root, "outside")
|
|
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
|
t.Fatalf("failed to create workspace: %v", err)
|
|
}
|
|
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
|
|
t.Fatalf("failed to create outside dir: %v", err)
|
|
}
|
|
|
|
tool, err := NewExecTool(workspace, true)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
result := tool.Execute(context.Background(), map[string]any{
|
|
"command": "pwd",
|
|
"working_dir": outsideDir,
|
|
})
|
|
|
|
if !result.IsError {
|
|
t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "blocked") {
|
|
t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
// TestShellTool_WorkingDir_SymlinkEscape verifies that a symlink inside the workspace
|
|
// pointing outside cannot be used as working_dir to escape the sandbox.
|
|
func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
|
|
root := t.TempDir()
|
|
workspace := filepath.Join(root, "workspace")
|
|
secretDir := filepath.Join(root, "secret")
|
|
if err := os.MkdirAll(workspace, 0o755); err != nil {
|
|
t.Fatalf("failed to create workspace: %v", err)
|
|
}
|
|
if err := os.MkdirAll(secretDir, 0o755); err != nil {
|
|
t.Fatalf("failed to create secret dir: %v", err)
|
|
}
|
|
os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0o644)
|
|
|
|
// symlink lives inside the workspace but resolves to secretDir outside it
|
|
link := filepath.Join(workspace, "escape")
|
|
if err := os.Symlink(secretDir, link); err != nil {
|
|
t.Skipf("symlinks not supported in this environment: %v", err)
|
|
}
|
|
|
|
tool, err := NewExecTool(workspace, true)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
result := tool.Execute(context.Background(), map[string]any{
|
|
"command": "cat secret.txt",
|
|
"working_dir": link,
|
|
})
|
|
|
|
if !result.IsError {
|
|
t.Fatalf("expected symlink working_dir escape to be blocked, got output: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "blocked") {
|
|
t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
// TestShellTool_RestrictToWorkspace verifies workspace restriction
|
|
func TestShellTool_RestrictToWorkspace(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
tool, err := NewExecTool(tmpDir, false)
|
|
if err != nil {
|
|
t.Errorf("unable to configure exec tool: %s", err)
|
|
}
|
|
|
|
tool.SetRestrictToWorkspace(true)
|
|
|
|
ctx := context.Background()
|
|
args := map[string]any{
|
|
"command": "cat ../../etc/passwd",
|
|
}
|
|
|
|
result := tool.Execute(ctx, args)
|
|
|
|
// Path traversal should be blocked
|
|
if !result.IsError {
|
|
t.Errorf("Expected path traversal to be blocked with restrictToWorkspace=true")
|
|
}
|
|
|
|
if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") {
|
|
t.Errorf(
|
|
"Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s",
|
|
result.ForLLM,
|
|
result.ForUser,
|
|
)
|
|
}
|
|
}
|
|
|
|
// --- guardCommand unit tests ---
|
|
|
|
// TestGuardCommand_RelativePathWithSlashes verifies that relative paths
|
|
// containing slashes (e.g., tests/cold/test.py, projects/terra-py-form)
|
|
// are NOT falsely blocked. This was a regression caused by the old regex
|
|
// matching "/cold/test.py" from "tests/cold/test.py" as an absolute path.
|
|
func TestGuardCommand_RelativePathWithSlashes(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
cmds := []string{
|
|
"pytest tests/cold/test_solver.py -v --tb=short",
|
|
"cd projects/terra-py-form && pytest",
|
|
"uv run pytest tests/cold/test_solver.py -v --tb=short",
|
|
"cat src/terra_py_form/cold/parser.py",
|
|
"python src/main.py --config config/dev.json",
|
|
}
|
|
|
|
for _, cmd := range cmds {
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result != "" {
|
|
t.Errorf("Relative path should not be blocked: %q → %s", cmd, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_VenvBinary verifies that .venv/bin/... paths are allowed
|
|
// (they are relative paths, not absolute).
|
|
func TestGuardCommand_VenvBinary(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
cmds := []string{
|
|
".venv/bin/python -m pytest",
|
|
".venv/bin/pytest tests/ -v",
|
|
".venv/bin/pip install -e .",
|
|
}
|
|
|
|
for _, cmd := range cmds {
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result != "" {
|
|
t.Errorf("Venv relative path should not be blocked: %q → %s", cmd, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_ExecutableBinaryAllowed verifies that absolute paths
|
|
// to executable files outside the workspace are allowed (system binaries).
|
|
func TestGuardCommand_ExecutableBinaryAllowed(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix executable permission test not applicable on Windows")
|
|
}
|
|
|
|
workspace := t.TempDir()
|
|
externalDir := t.TempDir()
|
|
|
|
// Create a fake executable outside the workspace
|
|
execPath := filepath.Join(externalDir, "mybin")
|
|
os.WriteFile(execPath, []byte("#!/bin/sh\necho ok"), 0o755)
|
|
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
cmd := execPath + " --help"
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result != "" {
|
|
t.Errorf("Executable binary outside workspace should be allowed: %q → %s", cmd, result)
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_ExecutableBinaryAllowed_Windows verifies that .exe files
|
|
// outside the workspace are allowed on Windows.
|
|
func TestGuardCommand_ExecutableBinaryAllowed_Windows(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("Windows-specific test")
|
|
}
|
|
|
|
workspace := t.TempDir()
|
|
externalDir := t.TempDir()
|
|
|
|
// Create a fake .exe outside the workspace
|
|
execPath := filepath.Join(externalDir, "tool.exe")
|
|
os.WriteFile(execPath, []byte("MZ"), 0o644)
|
|
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
cmd := execPath + " --version"
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result != "" {
|
|
t.Errorf("Windows .exe outside workspace should be allowed: %q → %s", cmd, result)
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_NonExecutableOutsideBlocked verifies that non-executable
|
|
// files outside the workspace are blocked (e.g., reading /etc/shadow).
|
|
func TestGuardCommand_NonExecutableOutsideBlocked(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix permission test not applicable on Windows")
|
|
}
|
|
|
|
workspace := t.TempDir()
|
|
externalDir := t.TempDir()
|
|
|
|
// Create a regular (non-executable) file outside workspace
|
|
dataFile := filepath.Join(externalDir, "secret.txt")
|
|
os.WriteFile(dataFile, []byte("secret data"), 0o644)
|
|
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
cmd := "cat " + dataFile
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result == "" {
|
|
t.Errorf("Non-executable file outside workspace should be blocked: %q", cmd)
|
|
}
|
|
if !strings.Contains(result, "path outside working dir") {
|
|
t.Errorf("Expected 'path outside working dir' message, got: %s", result)
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_NonExistentAbsolutePathBlocked verifies that absolute
|
|
// paths that don't exist are blocked (could be file creation outside workspace).
|
|
func TestGuardCommand_NonExistentAbsolutePathBlocked(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
// Use platform-appropriate absolute path
|
|
var cmd string
|
|
if runtime.GOOS == "windows" {
|
|
cmd = "echo hello > C:\\nonexistent_picoclaw_test_output"
|
|
} else {
|
|
cmd = "echo hello > /tmp/nonexistent_picoclaw_test_output"
|
|
}
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result == "" {
|
|
t.Errorf("Non-existent absolute path outside workspace should be blocked: %q", cmd)
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_FlagEmbeddedPathSkipped verifies that paths embedded in
|
|
// flags (e.g., -I/usr/local/include) are NOT extracted as absolute paths
|
|
// because the token starts with "-", not "/".
|
|
func TestGuardCommand_FlagEmbeddedPathSkipped(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
cmds := []string{
|
|
"gcc -I/usr/local/include -L/usr/lib main.c",
|
|
"g++ -std=c++17 -I/opt/include file.cpp",
|
|
"python --prefix=/usr/local script.py",
|
|
}
|
|
|
|
for _, cmd := range cmds {
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result != "" {
|
|
t.Errorf("Flag-embedded path should not be blocked: %q → %s", cmd, result)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_AbsolutePathInsideWorkspace verifies that absolute paths
|
|
// within the workspace are always allowed.
|
|
func TestGuardCommand_AbsolutePathInsideWorkspace(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
innerDir := filepath.Join(workspace, "projects", "myapp")
|
|
os.MkdirAll(innerDir, 0o755)
|
|
|
|
cmd := "ls " + innerDir
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result != "" {
|
|
t.Errorf("Absolute path inside workspace should be allowed: %q → %s", cmd, result)
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_PathTraversal verifies that various path traversal
|
|
// patterns are blocked.
|
|
func TestGuardCommand_PathTraversal(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
cmds := []string{
|
|
"cat ../../etc/passwd",
|
|
"cat ../../../etc/shadow",
|
|
"ls projects/../../../../etc",
|
|
}
|
|
|
|
for _, cmd := range cmds {
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result == "" {
|
|
t.Errorf("Path traversal should be blocked: %q", cmd)
|
|
}
|
|
if !strings.Contains(result, "path traversal") {
|
|
t.Errorf("Expected 'path traversal' message, got: %s", result)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_CdWithAbsoluteWorkspacePath verifies that cd to an
|
|
// absolute path within the workspace followed by other commands is allowed.
|
|
func TestGuardCommand_CdWithAbsoluteWorkspacePath(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
innerDir := filepath.Join(workspace, "projects", "foo")
|
|
os.MkdirAll(innerDir, 0o755)
|
|
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
cmd := "cd " + innerDir + " && ls -la"
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result != "" {
|
|
t.Errorf("cd to workspace subdir should be allowed: %q → %s", cmd, result)
|
|
}
|
|
}
|
|
|
|
func TestGuardCommand_AgentCLISlashCommand(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
// Agent CLI slash commands (e.g., "/review") are not file paths.
|
|
// They should be allowed because they don't exist on disk.
|
|
cmds := []string{
|
|
`codex exec --yolo "/review skip-git-repo-check"`,
|
|
`claude "/review"`,
|
|
`gemini "/help"`,
|
|
}
|
|
for _, cmd := range cmds {
|
|
result := tool.guardCommand(cmd, workspace)
|
|
if result != "" {
|
|
t.Errorf("Agent CLI slash command should not be blocked: %q → %s", cmd, result)
|
|
}
|
|
}
|
|
|
|
// Non-agent commands with absolute paths should still be blocked.
|
|
if runtime.GOOS != "windows" {
|
|
blocked := `cat /etc/hosts`
|
|
result := tool.guardCommand(blocked, workspace)
|
|
if result == "" {
|
|
t.Errorf("Non-agent command with absolute path should be blocked: %q", blocked)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_DenyPattern_IncludesPattern verifies that deny-match
|
|
// error messages include the matched pattern string.
|
|
func TestGuardCommand_DenyPattern_IncludesPattern(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
tool, _ := NewExecTool(workspace, true)
|
|
// Also add a custom deny pattern for precise matching.
|
|
tool.denyPatterns = append(tool.denyPatterns, regexp.MustCompile(`\bdangerous_cmd\b`))
|
|
|
|
result := tool.guardCommand("dangerous_cmd --force", workspace)
|
|
if result == "" {
|
|
t.Fatal("expected deny pattern to block the command")
|
|
}
|
|
if !strings.Contains(result, "deny pattern") {
|
|
t.Errorf("expected 'deny pattern' in message, got: %s", result)
|
|
}
|
|
if !strings.Contains(result, `\bdangerous_cmd\b`) {
|
|
t.Errorf("expected pattern string in message, got: %s", result)
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_Allowlist_ShowsRules verifies that allowlist violation
|
|
// messages include all configured rules.
|
|
func TestGuardCommand_Allowlist_ShowsRules(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
tool, _ := NewExecTool(workspace, true)
|
|
tool.SetAllowRules([]string{"go test", "git"})
|
|
|
|
result := tool.guardCommand("curl http://example.com", workspace)
|
|
if result == "" {
|
|
t.Fatal("expected allowlist to block the command")
|
|
}
|
|
if !strings.Contains(result, "not in allowlist") {
|
|
t.Errorf("expected 'not in allowlist' in message, got: %s", result)
|
|
}
|
|
if !strings.Contains(result, "go test") || !strings.Contains(result, "git") {
|
|
t.Errorf("expected allowlist rules in message, got: %s", result)
|
|
}
|
|
}
|
|
|
|
// TestGuardCommand_PathOutside_IncludesPath verifies that workspace-escape
|
|
// messages include the offending path token.
|
|
func TestGuardCommand_PathOutside_IncludesPath(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Unix absolute path test not applicable on Windows")
|
|
}
|
|
|
|
workspace := t.TempDir()
|
|
externalDir := t.TempDir()
|
|
dataFile := filepath.Join(externalDir, "secret.txt")
|
|
os.WriteFile(dataFile, []byte("secret"), 0o644)
|
|
|
|
tool, _ := NewExecTool(workspace, true)
|
|
|
|
result := tool.guardCommand("cat "+dataFile, workspace)
|
|
if result == "" {
|
|
t.Fatal("expected path outside workspace to be blocked")
|
|
}
|
|
if !strings.Contains(result, "path outside working dir") {
|
|
t.Errorf("expected 'path outside working dir' in message, got: %s", result)
|
|
}
|
|
if !strings.Contains(result, dataFile) {
|
|
t.Errorf("expected offending path %q in message, got: %s", dataFile, result)
|
|
}
|
|
}
|
|
|
|
// --- Background process tests ---
|
|
|
|
func TestExecTool_Bg_StartAndOutput(t *testing.T) {
|
|
tool, _ := NewExecTool("", false)
|
|
defer tool.Shutdown()
|
|
|
|
var cmd string
|
|
if runtime.GOOS == "windows" {
|
|
cmd = "Write-Output 'hello from bg'; Start-Sleep -Seconds 30"
|
|
} else {
|
|
cmd = "echo 'hello from bg'; sleep 30"
|
|
}
|
|
|
|
result := tool.Execute(context.Background(), map[string]any{
|
|
"command": cmd,
|
|
"background": true,
|
|
})
|
|
if result.IsError {
|
|
t.Fatalf("failed to start bg process: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "bg-1") {
|
|
t.Errorf("expected bg-1 in result, got: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "Background process started") {
|
|
t.Errorf("expected start message, got: %s", result.ForLLM)
|
|
}
|
|
|
|
// Get output
|
|
outputResult := tool.Execute(context.Background(), map[string]any{
|
|
"bg_action": "output",
|
|
"bg_id": "bg-1",
|
|
})
|
|
if outputResult.IsError {
|
|
t.Fatalf("failed to get output: %s", outputResult.ForLLM)
|
|
}
|
|
if !strings.Contains(outputResult.ForLLM, "hello from bg") {
|
|
t.Errorf("expected 'hello from bg' in output, got: %s", outputResult.ForLLM)
|
|
}
|
|
if !strings.Contains(outputResult.ForLLM, "running") {
|
|
t.Errorf("expected 'running' status, got: %s", outputResult.ForLLM)
|
|
}
|
|
}
|
|
|
|
func TestExecTool_Bg_Kill(t *testing.T) {
|
|
tool, _ := NewExecTool("", false)
|
|
defer tool.Shutdown()
|
|
|
|
var cmd string
|
|
if runtime.GOOS == "windows" {
|
|
cmd = "Start-Sleep -Seconds 60"
|
|
} else {
|
|
cmd = "sleep 60"
|
|
}
|
|
|
|
result := tool.Execute(context.Background(), map[string]any{
|
|
"command": cmd,
|
|
"background": true,
|
|
})
|
|
if result.IsError {
|
|
t.Fatalf("failed to start bg process: %s", result.ForLLM)
|
|
}
|
|
|
|
// Kill it
|
|
killResult := tool.Execute(context.Background(), map[string]any{
|
|
"bg_action": "kill",
|
|
"bg_id": "bg-1",
|
|
})
|
|
if killResult.IsError {
|
|
t.Fatalf("failed to kill: %s", killResult.ForLLM)
|
|
}
|
|
if !strings.Contains(killResult.ForLLM, "terminated") {
|
|
t.Errorf("expected 'terminated' message, got: %s", killResult.ForLLM)
|
|
}
|
|
|
|
// Process should no longer be in the map
|
|
procs := tool.BgProcesses()
|
|
if _, ok := procs["bg-1"]; ok {
|
|
t.Errorf("expected bg-1 to be removed after kill")
|
|
}
|
|
}
|
|
|
|
func TestExecTool_Bg_ExitedProcess(t *testing.T) {
|
|
tool, _ := NewExecTool("", false)
|
|
defer tool.Shutdown()
|
|
|
|
var cmd string
|
|
if runtime.GOOS == "windows" {
|
|
cmd = "Write-Output 'quick exit'"
|
|
} else {
|
|
cmd = "echo 'quick exit'"
|
|
}
|
|
|
|
result := tool.Execute(context.Background(), map[string]any{
|
|
"command": cmd,
|
|
"background": true,
|
|
})
|
|
if result.IsError {
|
|
t.Fatalf("failed to start bg process: %s", result.ForLLM)
|
|
}
|
|
|
|
// Wait for process to exit (initial capture is 3s, so after that it should be done)
|
|
time.Sleep(4 * time.Second)
|
|
|
|
// Get output — should show exited
|
|
outputResult := tool.Execute(context.Background(), map[string]any{
|
|
"bg_action": "output",
|
|
"bg_id": "bg-1",
|
|
})
|
|
if outputResult.IsError {
|
|
t.Fatalf("failed to get output: %s", outputResult.ForLLM)
|
|
}
|
|
if !strings.Contains(outputResult.ForLLM, "exited") {
|
|
t.Errorf("expected 'exited' in output, got: %s", outputResult.ForLLM)
|
|
}
|
|
if !strings.Contains(outputResult.ForLLM, "quick exit") {
|
|
t.Errorf("expected 'quick exit' in output, got: %s", outputResult.ForLLM)
|
|
}
|
|
}
|
|
|
|
func TestExecTool_Bg_InvalidID(t *testing.T) {
|
|
tool, _ := NewExecTool("", false)
|
|
defer tool.Shutdown()
|
|
|
|
// Output for non-existent ID
|
|
result := tool.Execute(context.Background(), map[string]any{
|
|
"bg_action": "output",
|
|
"bg_id": "bg-999",
|
|
})
|
|
if !result.IsError {
|
|
t.Fatalf("expected error for invalid bg_id")
|
|
}
|
|
if !strings.Contains(result.ForLLM, "not found") {
|
|
t.Errorf("expected 'not found' message, got: %s", result.ForLLM)
|
|
}
|
|
|
|
// Kill for non-existent ID
|
|
result = tool.Execute(context.Background(), map[string]any{
|
|
"bg_action": "kill",
|
|
"bg_id": "bg-999",
|
|
})
|
|
if !result.IsError {
|
|
t.Fatalf("expected error for invalid bg_id")
|
|
}
|
|
}
|
|
|
|
func TestExecTool_Bg_InitialOutputCapture(t *testing.T) {
|
|
tool, _ := NewExecTool("", false)
|
|
defer tool.Shutdown()
|
|
|
|
var cmd string
|
|
if runtime.GOOS == "windows" {
|
|
cmd = "Write-Output 'initial line 1'; Write-Output 'initial line 2'; Start-Sleep -Seconds 30"
|
|
} else {
|
|
cmd = "echo 'initial line 1'; echo 'initial line 2'; sleep 30"
|
|
}
|
|
|
|
result := tool.Execute(context.Background(), map[string]any{
|
|
"command": cmd,
|
|
"background": true,
|
|
})
|
|
if result.IsError {
|
|
t.Fatalf("failed to start bg process: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "initial line 1") {
|
|
t.Errorf("expected 'initial line 1' in initial output, got: %s", result.ForLLM)
|
|
}
|
|
if !strings.Contains(result.ForLLM, "initial line 2") {
|
|
t.Errorf("expected 'initial line 2' in initial output, got: %s", result.ForLLM)
|
|
}
|
|
}
|
|
|
|
func TestExecTool_Bg_RuntimeStatus(t *testing.T) {
|
|
tool, _ := NewExecTool("", false)
|
|
defer tool.Shutdown()
|
|
|
|
// No bg processes — should return empty
|
|
if s := tool.RuntimeStatus(); s != "" {
|
|
t.Errorf("expected empty runtime status with no bg processes, got: %s", s)
|
|
}
|
|
|
|
var cmd string
|
|
if runtime.GOOS == "windows" {
|
|
cmd = "Start-Sleep -Seconds 30"
|
|
} else {
|
|
cmd = "sleep 30"
|
|
}
|
|
|
|
tool.Execute(context.Background(), map[string]any{
|
|
"command": cmd,
|
|
"background": true,
|
|
})
|
|
|
|
status := tool.RuntimeStatus()
|
|
if !strings.Contains(status, "Background Processes") {
|
|
t.Errorf("expected 'Background Processes' section, got: %s", status)
|
|
}
|
|
if !strings.Contains(status, "bg-1") {
|
|
t.Errorf("expected 'bg-1' in status, got: %s", status)
|
|
}
|
|
if !strings.Contains(status, "running") {
|
|
t.Errorf("expected 'running' in status, got: %s", status)
|
|
}
|
|
}
|
|
|
|
func TestExecTool_Bg_Shutdown(t *testing.T) {
|
|
tool, _ := NewExecTool("", false)
|
|
|
|
var cmd string
|
|
if runtime.GOOS == "windows" {
|
|
cmd = "Start-Sleep -Seconds 60"
|
|
} else {
|
|
cmd = "sleep 60"
|
|
}
|
|
|
|
tool.Execute(context.Background(), map[string]any{
|
|
"command": cmd,
|
|
"background": true,
|
|
})
|
|
tool.Execute(context.Background(), map[string]any{
|
|
"command": cmd,
|
|
"background": true,
|
|
})
|
|
|
|
// Both should be running
|
|
procs := tool.BgProcesses()
|
|
for _, bp := range procs {
|
|
if !bp.isRunning() {
|
|
t.Errorf("expected process to be running before shutdown")
|
|
}
|
|
}
|
|
|
|
// Shutdown
|
|
tool.Shutdown()
|
|
|
|
// All should be done
|
|
procs = tool.BgProcesses()
|
|
for _, bp := range procs {
|
|
if bp.isRunning() {
|
|
t.Errorf("expected process to be stopped after shutdown")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRingBuffer(t *testing.T) {
|
|
t.Run("Write and String", func(t *testing.T) {
|
|
rb := newRingBuffer(100)
|
|
rb.Write([]byte("hello "))
|
|
rb.Write([]byte("world"))
|
|
if got := rb.String(); got != "hello world" {
|
|
t.Errorf("expected 'hello world', got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("Lines", func(t *testing.T) {
|
|
rb := newRingBuffer(100)
|
|
rb.Write([]byte("line1\nline2\nline3\nline4\nline5\n"))
|
|
lines := rb.Lines(3)
|
|
if len(lines) != 3 {
|
|
t.Fatalf("expected 3 lines, got %d", len(lines))
|
|
}
|
|
if lines[0] != "line3" || lines[1] != "line4" || lines[2] != "line5" {
|
|
t.Errorf("unexpected lines: %v", lines)
|
|
}
|
|
})
|
|
|
|
t.Run("Match", func(t *testing.T) {
|
|
rb := newRingBuffer(100)
|
|
rb.Write([]byte("starting...\nServer ready on port 3000\nwaiting...\n"))
|
|
|
|
re := regexp.MustCompile(`ready.*port`)
|
|
match := rb.Match(re)
|
|
if match == "" {
|
|
t.Fatal("expected match but got empty string")
|
|
}
|
|
if !strings.Contains(match, "ready") {
|
|
t.Errorf("expected match to contain 'ready', got: %s", match)
|
|
}
|
|
|
|
// Non-matching pattern
|
|
re2 := regexp.MustCompile(`never_match`)
|
|
match2 := rb.Match(re2)
|
|
if match2 != "" {
|
|
t.Errorf("expected no match, got: %s", match2)
|
|
}
|
|
})
|
|
|
|
t.Run("Overflow", func(t *testing.T) {
|
|
rb := newRingBuffer(10) // small buffer
|
|
rb.Write([]byte("1234567890ABCDEF"))
|
|
got := rb.String()
|
|
if len(got) != 10 {
|
|
t.Errorf("expected buffer to be 10 bytes, got %d", len(got))
|
|
}
|
|
// Should keep the last 10 bytes
|
|
if got != "7890ABCDEF" {
|
|
t.Errorf("expected '7890ABCDEF', got %q", got)
|
|
}
|
|
})
|
|
|
|
t.Run("Len", func(t *testing.T) {
|
|
rb := newRingBuffer(100)
|
|
if rb.Len() != 0 {
|
|
t.Errorf("expected 0 length initially")
|
|
}
|
|
rb.Write([]byte("hello"))
|
|
if rb.Len() != 5 {
|
|
t.Errorf("expected 5, got %d", rb.Len())
|
|
}
|
|
})
|
|
|
|
t.Run("Empty Lines", func(t *testing.T) {
|
|
rb := newRingBuffer(100)
|
|
lines := rb.Lines(5)
|
|
if lines != nil {
|
|
t.Errorf("expected nil for empty buffer, got: %v", lines)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestExecTool_Bg_RingBufferOverflow(t *testing.T) {
|
|
tool, _ := NewExecTool("", false)
|
|
defer tool.Shutdown()
|
|
|
|
// Generate output larger than 32KB ring buffer
|
|
var cmd string
|
|
if runtime.GOOS == "windows" {
|
|
cmd = "1..2000 | ForEach-Object { Write-Output ('x' * 50) }; Start-Sleep -Seconds 30"
|
|
} else {
|
|
cmd = "yes 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' | head -n 2000; sleep 30"
|
|
}
|
|
|
|
result := tool.Execute(context.Background(), map[string]any{
|
|
"command": cmd,
|
|
"background": true,
|
|
})
|
|
if result.IsError {
|
|
t.Fatalf("failed to start bg process: %s", result.ForLLM)
|
|
}
|
|
|
|
// Wait for output to accumulate
|
|
time.Sleep(5 * time.Second)
|
|
|
|
// Get output — ring buffer should have truncated old data
|
|
outputResult := tool.Execute(context.Background(), map[string]any{
|
|
"bg_action": "output",
|
|
"bg_id": "bg-1",
|
|
})
|
|
if outputResult.IsError {
|
|
t.Fatalf("failed to get output: %s", outputResult.ForLLM)
|
|
}
|
|
|
|
// The output should contain data but be bounded by the ring buffer size
|
|
procs := tool.BgProcesses()
|
|
bp := procs["bg-1"]
|
|
if bp == nil {
|
|
t.Fatal("bg-1 not found")
|
|
}
|
|
bufLen := bp.output.Len()
|
|
if bufLen > bgRingBufSize {
|
|
t.Errorf("ring buffer exceeded max size: %d > %d", bufLen, bgRingBufSize)
|
|
}
|
|
}
|
|
|
|
// TestIsLocalHost verifies localhost and RFC 1918 detection using net package.
|
|
func TestIsLocalHost(t *testing.T) {
|
|
tests := []struct {
|
|
host string
|
|
want bool
|
|
}{
|
|
// Loopback / localhost
|
|
{"localhost", true},
|
|
{"LOCALHOST", true},
|
|
{"127.0.0.1", true},
|
|
{"127.0.0.2", true},
|
|
{"::1", true},
|
|
// RFC 1918 private ranges
|
|
{"10.0.0.1", true},
|
|
{"10.255.255.255", true},
|
|
{"172.16.0.1", true},
|
|
{"172.31.255.255", true},
|
|
{"192.168.0.1", true},
|
|
{"192.168.1.100", true},
|
|
// Public addresses
|
|
{"8.8.8.8", false},
|
|
{"1.1.1.1", false},
|
|
{"example.com", false},
|
|
{"api.github.com", false},
|
|
// Edge: non-private but routable private-looking address
|
|
{"172.15.255.255", false}, // just below 172.16/12
|
|
{"172.32.0.0", false}, // just above 172.31/12
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := isLocalHost(tt.host)
|
|
if got != tt.want {
|
|
t.Errorf("isLocalHost(%q) = %v, want %v", tt.host, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCheckCurlLocalNet verifies URL-level enforcement for curl/wget commands.
|
|
func TestCheckCurlLocalNet(t *testing.T) {
|
|
tests := []struct {
|
|
cmd string
|
|
wantErr bool
|
|
}{
|
|
// Allowed: localhost and private IPs
|
|
{"curl http://localhost:3000/health", false},
|
|
{"curl -v http://127.0.0.1:8080/api/status", false},
|
|
{"wget http://192.168.1.10/file.bin", false},
|
|
{"curl -X POST http://10.0.0.5:9000/webhook", false},
|
|
// Blocked: public addresses
|
|
{"curl http://example.com", true},
|
|
{"wget https://releases.github.com/v1.tar.gz", true},
|
|
{"curl http://8.8.8.8/data", true},
|
|
// Allowed: no http URL (e.g. --help, --version — no network access)
|
|
{"curl --help", false},
|
|
{"curl --version", false},
|
|
{"wget --help", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
errMsg := checkCurlLocalNet(tt.cmd)
|
|
gotErr := errMsg != ""
|
|
if gotErr != tt.wantErr {
|
|
t.Errorf("checkCurlLocalNet(%q): gotErr=%v wantErr=%v (msg: %q)",
|
|
tt.cmd, gotErr, tt.wantErr, errMsg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExecTool_LocalNetOnly verifies curl/wget blocking via SetLocalNetOnly.
|
|
func TestExecTool_LocalNetOnly(t *testing.T) {
|
|
tool, _ := NewExecTool("", false)
|
|
tool.SetLocalNetOnly(true)
|
|
|
|
tests := []struct {
|
|
cmd string
|
|
wantErr bool
|
|
}{
|
|
{"curl http://localhost:3000", false},
|
|
{"curl http://example.com", true},
|
|
{"echo hello", false}, // non-curl not affected
|
|
}
|
|
|
|
ctx := context.Background()
|
|
for _, tt := range tests {
|
|
result := tool.Execute(ctx, map[string]any{"command": tt.cmd})
|
|
if tt.wantErr && !result.IsError {
|
|
t.Errorf("cmd %q: expected blocked, but succeeded", tt.cmd)
|
|
}
|
|
if !tt.wantErr && result.IsError && strings.Contains(result.ForLLM, "safety guard") {
|
|
t.Errorf("cmd %q: expected allowed, but safety guard blocked: %s", tt.cmd, result.ForLLM)
|
|
}
|
|
}
|
|
}
|