diff --git a/pkg/agent/cmd_helpers_test.go b/pkg/agent/cmd_helpers_test.go new file mode 100644 index 000000000..f4ca967e6 --- /dev/null +++ b/pkg/agent/cmd_helpers_test.go @@ -0,0 +1,104 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveEditPath_AbsoluteBlocked(t *testing.T) { + workspace := t.TempDir() + _, err := resolveEditPath("/etc/passwd", workspace, workspace) + if err == nil { + t.Fatal("Expected absolute path outside workspace to be blocked") + } +} + +func TestResolveEditPath_TildeIsWorkspace(t *testing.T) { + workspace := t.TempDir() + // Create a file so the path resolves + os.WriteFile(filepath.Join(workspace, "test.txt"), []byte("hi"), 0o644) + + path, err := resolveEditPath("~/test.txt", workspace, workspace) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + expected := filepath.Join(workspace, "test.txt") + if path != expected { + t.Errorf("Expected %s, got %s", expected, path) + } +} + +func TestResolveEditPath_BareTildeIsWorkspace(t *testing.T) { + workspace := t.TempDir() + path, err := resolveEditPath("~", workspace, workspace) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if path != workspace { + t.Errorf("Expected %s, got %s", workspace, path) + } +} + +func TestResolveEditPath_TraversalBlocked(t *testing.T) { + workspace := t.TempDir() + _, err := resolveEditPath("../../etc/passwd", workspace, workspace) + if err == nil { + t.Fatal("Expected path traversal to be blocked") + } +} + +func TestResolveEditPath_SymlinkBlocked(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + os.MkdirAll(workspace, 0o755) + secret := filepath.Join(root, "secret.txt") + os.WriteFile(secret, []byte("secret"), 0o644) + link := filepath.Join(workspace, "link.txt") + if err := os.Symlink(secret, link); err != nil { + t.Skip("symlinks not supported") + } + _, err := resolveEditPath("link.txt", workspace, workspace) + if err == nil { + t.Fatal("Expected symlink escape to be blocked") + } +} + +func TestResolveEditPath_ValidRelative(t *testing.T) { + workspace := t.TempDir() + subdir := filepath.Join(workspace, "subdir") + os.MkdirAll(subdir, 0o755) + testFile := filepath.Join(subdir, "test.txt") + os.WriteFile(testFile, []byte("content"), 0o644) + + path, err := resolveEditPath("subdir/test.txt", workspace, workspace) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if path != testFile { + t.Errorf("Expected %s, got %s", testFile, path) + } +} + +func TestShortenHomePath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot get home dir") + } + + tests := []struct { + input string + expected string + }{ + {home, "~"}, + {filepath.Join(home, "projects"), "~/projects"}, + {"/tmp/other", "/tmp/other"}, + } + + for _, tt := range tests { + result := shortenHomePath(tt.input) + if result != tt.expected { + t.Errorf("shortenHomePath(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4414398b1..63c0754f2 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -631,3 +631,37 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory)) } } + +// TestHandleExtensionCommand_EmojiPassthrough verifies that emoji-like +// messages starting with : are not intercepted as commands. +func TestHandleExtensionCommand_EmojiPassthrough(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + Model: "test-model", + MaxTokens: 4096, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + emojiInputs := []string{":)", ":D", ":heart:", ":thinking:", ":-)", ":100:"} + for _, input := range emojiInputs { + _, handled := al.handleExtensionCommand(input) + if handled { + t.Errorf("Expected %q to pass through (not handled), but it was handled", input) + } + } + + // Known commands should still be handled + knownCommands := []string{":help", ":usage"} + for _, cmd := range knownCommands { + _, handled := al.handleExtensionCommand(cmd) + if !handled { + t.Errorf("Expected %q to be handled, but it was not", cmd) + } + } +} diff --git a/pkg/agent/ls_helpers_test.go b/pkg/agent/ls_helpers_test.go new file mode 100644 index 000000000..fd1b34e5d --- /dev/null +++ b/pkg/agent/ls_helpers_test.go @@ -0,0 +1,64 @@ +package agent + +import "testing" + +func TestIsLsCommand(t *testing.T) { + tests := []struct { + cmd string + want bool + }{ + {"ls", true}, + {"ls -la", true}, + {"ls /tmp", true}, + {"lsof", false}, + {"echo ls", false}, + {"", false}, + } + for _, tt := range tests { + if got := isLsCommand(tt.cmd); got != tt.want { + t.Errorf("isLsCommand(%q) = %v, want %v", tt.cmd, got, tt.want) + } + } +} + +func TestHasLongFlag(t *testing.T) { + tests := []struct { + cmd string + want bool + }{ + {"ls", false}, + {"ls -l", true}, + {"ls -la", true}, + {"ls -al", true}, + {"ls --color", false}, + {"ls -a /tmp", false}, + {"ls -l --color /tmp", true}, + } + for _, tt := range tests { + if got := hasLongFlag(tt.cmd); got != tt.want { + t.Errorf("hasLongFlag(%q) = %v, want %v", tt.cmd, got, tt.want) + } + } +} + +func TestIsPermString(t *testing.T) { + tests := []struct { + s string + want bool + }{ + {"drwxr-xr-x", true}, + {"-rw-r--r--", true}, + {"lrwxrwxrwx", true}, + {"-rwsr-xr-x", true}, // setuid + {"drwxrwxrwt", true}, // sticky + {"hello world", false}, // wrong length/chars + {"----------", true}, + {"xrwxrwxrwx", false}, // invalid first char + {"", false}, + } + for _, tt := range tests { + if got := isPermString(tt.s); got != tt.want { + t.Errorf("isPermString(%q) = %v, want %v", tt.s, got, tt.want) + } + } +} diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index 6d35815e8..a8bd603cc 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -272,3 +272,27 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) { ) } } + +// TestGuardCommand_DotSlashExecutable verifies that ./executable style +// commands are NOT blocked by the path extraction regex in guardCommand. +func TestGuardCommand_DotSlashExecutable(t *testing.T) { + tmpDir := t.TempDir() + tool := NewExecTool(tmpDir, true) + + // Create a test script in the workspace + scriptPath := filepath.Join(tmpDir, "test.sh") + os.WriteFile(scriptPath, []byte("#!/bin/sh\necho ok"), 0o755) + + ctx := context.Background() + result := tool.Execute(ctx, map[string]any{ + "command": "./test.sh", + "working_dir": tmpDir, + }) + + if result.IsError { + t.Errorf("Expected ./test.sh to be allowed, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "ok") { + t.Errorf("Expected output 'ok', got: %s", result.ForLLM) + } +}