test: add unit tests for edit path, cd, guard, ls helpers
- resolveEditPath: absolute blocked, ~ maps to workspace, traversal
blocked, symlink escape blocked, valid relative works
- shortenHomePath: home/subpath/other cases
- isLsCommand, hasLongFlag, isPermString: table-driven tests
- guardCommand: verify ./executable is not blocked
- handleExtensionCommand: verify :) :D 🤔 pass through
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
027a1b3ee7
commit
9fcce86fbd
4 changed files with 226 additions and 0 deletions
104
pkg/agent/cmd_helpers_test.go
Normal file
104
pkg/agent/cmd_helpers_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
pkg/agent/ls_helpers_test.go
Normal file
64
pkg/agent/ls_helpers_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue