github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/search_test.go (about)

     1  package agenttoolset
     2  
     3  import (
     4  	"context"
     5  	"os"
     6  	"path/filepath"
     7  	"strings"
     8  	"testing"
     9  	"time"
    10  
    11  	"github.com/stretchr/testify/require"
    12  )
    13  
    14  func TestExecGlob(t *testing.T) {
    15  	work := t.TempDir()
    16  	env := &AgentToolContext{Workdir: work}
    17  
    18  	write := func(rel string, mtime time.Time) {
    19  		full := filepath.Join(work, rel)
    20  		require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
    21  		require.NoError(t, os.WriteFile(full, []byte("x"), 0o644))
    22  		require.NoError(t, os.Chtimes(full, mtime, mtime))
    23  	}
    24  	now := time.Now()
    25  	write("a.go", now.Add(-2*time.Hour))
    26  	write("b.go", now)
    27  	write("sub/c.go", now.Add(-1*time.Hour))
    28  	write("d.txt", now)
    29  
    30  	tests := []struct {
    31  		description string
    32  		pattern     string
    33  		assert      func(t *testing.T, lines []string)
    34  		wantErr     string
    35  	}{
    36  		{
    37  			description: "doublestar pattern matches across directories and excludes non-matching extensions",
    38  			pattern:     "**/*.go",
    39  			assert: func(t *testing.T, lines []string) {
    40  				require.Len(t, lines, 3)
    41  				for _, l := range lines {
    42  					require.True(t, strings.HasSuffix(l, ".go"), "non-go result leaked: %q", l)
    43  				}
    44  			},
    45  		},
    46  		{
    47  			description: "results are ordered newest-mtime first so the model sees the most recently touched files",
    48  			pattern:     "**/*.go",
    49  			assert: func(t *testing.T, lines []string) {
    50  				require.True(t, strings.HasSuffix(lines[0], "b.go"), "expected b.go first, got %q", lines[0])
    51  				require.True(t, strings.HasSuffix(lines[len(lines)-1], "a.go"), "expected a.go last, got %q", lines[len(lines)-1])
    52  			},
    53  		},
    54  		{
    55  			description: "empty pattern is rejected before walking the filesystem",
    56  			pattern:     "",
    57  			wantErr:     "pattern is required",
    58  		},
    59  		{
    60  			description: "absolute pattern is always rejected",
    61  			pattern:     "/etc/*",
    62  			wantErr:     "pass a relative pattern",
    63  		},
    64  		{
    65  			description: "pattern containing a .. segment is rejected so it cannot escape the workdir",
    66  			pattern:     "../*.go",
    67  			wantErr:     "must not contain",
    68  		},
    69  		{
    70  			description: "doublestar pattern with an embedded .. segment is also rejected",
    71  			pattern:     "**/../*.go",
    72  			wantErr:     "must not contain",
    73  		},
    74  	}
    75  
    76  	for _, tc := range tests {
    77  		t.Run(tc.description, func(t *testing.T) {
    78  			out, isErr := execGlob(context.Background(), mustJSON(t, map[string]any{"pattern": tc.pattern}), env)
    79  			require.Equal(t, tc.wantErr != "", isErr, "output=%q", out)
    80  			if tc.wantErr != "" {
    81  				require.Contains(t, out, tc.wantErr)
    82  				return
    83  			}
    84  			if tc.assert == nil {
    85  				return
    86  			}
    87  			tc.assert(t, strings.Split(strings.TrimSpace(out), "\n"))
    88  		})
    89  	}
    90  
    91  	t.Run("pattern with no matches returns the sentinel string rather than empty output", func(t *testing.T) {
    92  		out, isErr := execGlob(context.Background(), mustJSON(t, map[string]any{"pattern": "*.nomatch"}), env)
    93  		require.False(t, isErr)
    94  		require.Equal(t, "no matches", out)
    95  	})
    96  
    97  	t.Run("path argument scopes the search to a subdirectory of the workdir", func(t *testing.T) {
    98  		out, isErr := execGlob(context.Background(), mustJSON(t, map[string]any{"pattern": "*.go", "path": "sub"}), env)
    99  		require.False(t, isErr)
   100  		require.Contains(t, out, "c.go")
   101  		require.NotContains(t, out, "a.go")
   102  	})
   103  
   104  	t.Run("a symlinked workdir is searched through its canonical path by default", func(t *testing.T) {
   105  		link := filepath.Join(t.TempDir(), "link")
   106  		require.NoError(t, os.Symlink(work, link))
   107  		out, isErr := execGlob(context.Background(), mustJSON(t, map[string]any{"pattern": "**/*.go"}), &AgentToolContext{Workdir: link})
   108  		require.False(t, isErr, "output=%q", out)
   109  		lines := strings.Split(strings.TrimSpace(out), "\n")
   110  		require.Len(t, lines, 3)
   111  		for _, l := range lines {
   112  			require.True(t, strings.HasPrefix(l, canonicalRoot(work)+string(filepath.Separator)), "result %q is not under the canonical workdir", l)
   113  		}
   114  	})
   115  }
   116  
   117  // TestGlobAndGrepReachAnAllowedRoot: a memory folder mounted outside the
   118  // workdir is searchable when listed in AllowedRoots, and a directory that is
   119  // in neither is refused by both tools.
   120  func TestGlobAndGrepReachAnAllowedRoot(t *testing.T) {
   121  	tmp := t.TempDir()
   122  	work := filepath.Join(tmp, "work")
   123  	mount := filepath.Join(tmp, "mnt", "notes")
   124  	outside := filepath.Join(tmp, "outside")
   125  	for _, d := range []string{work, mount, outside} {
   126  		require.NoError(t, os.MkdirAll(d, 0o755))
   127  	}
   128  	require.NoError(t, os.WriteFile(filepath.Join(mount, "note.md"), []byte("remembered fact"), 0o644))
   129  	env := &AgentToolContext{Workdir: work, AllowedRoots: []string{mount}}
   130  	const refused = "is outside the session's working directory and its other permitted directories"
   131  
   132  	out, isErr := execGlob(context.Background(), mustJSON(t, map[string]any{"pattern": "*.md", "path": mount}), env)
   133  	require.False(t, isErr, "output=%q", out)
   134  	require.Equal(t, filepath.Join(canonicalRoot(mount), "note.md"), out)
   135  
   136  	for _, tc := range []struct {
   137  		description string
   138  		hidePath    bool
   139  	}{
   140  		{description: "grep via ripgrep when it is on PATH"},
   141  		{description: "grep via the built-in walker", hidePath: true},
   142  	} {
   143  		t.Run(tc.description, func(t *testing.T) {
   144  			if tc.hidePath {
   145  				t.Setenv("PATH", "")
   146  			}
   147  			out, isErr := execGrep(context.Background(), mustJSON(t, map[string]any{"pattern": "remembered", "path": mount}), env)
   148  			require.False(t, isErr, "output=%q", out)
   149  			require.Contains(t, out, "note.md")
   150  		})
   151  	}
   152  
   153  	out, isErr = execGlob(context.Background(), mustJSON(t, map[string]any{"pattern": "*", "path": outside}), env)
   154  	require.True(t, isErr)
   155  	require.Contains(t, out, refused)
   156  	out, isErr = execGrep(context.Background(), mustJSON(t, map[string]any{"pattern": "x", "path": outside}), env)
   157  	require.True(t, isErr)
   158  	require.Contains(t, out, refused)
   159  }
   160  
   161  func TestExecGrep(t *testing.T) {
   162  	work := t.TempDir()
   163  	env := &AgentToolContext{Workdir: work}
   164  	require.NoError(t, os.WriteFile(filepath.Join(work, "a.txt"), []byte("hello world\nfoo bar\nhello again"), 0o644))
   165  	require.NoError(t, os.WriteFile(filepath.Join(work, "b.txt"), []byte("nothing here"), 0o644))
   166  
   167  	tests := []struct {
   168  		description string
   169  		pattern     string
   170  		hidePath    bool
   171  		assert      func(t *testing.T, out string)
   172  		wantErr     bool
   173  	}{
   174  		{
   175  			description: "literal pattern matching multiple lines reports each hit with file:line: prefix",
   176  			pattern:     "hello",
   177  			assert: func(t *testing.T, out string) {
   178  				require.Contains(t, out, "a.txt:1:")
   179  				require.Contains(t, out, "a.txt:3:")
   180  				require.NotContains(t, out, "b.txt")
   181  			},
   182  		},
   183  		{
   184  			description: "no-match pattern returns the sentinel rather than an error so the model can continue",
   185  			pattern:     "zzz_absent",
   186  			assert: func(t *testing.T, out string) {
   187  				require.Equal(t, "no matches", out)
   188  			},
   189  		},
   190  		{
   191  			description: "empty pattern is rejected as a validation error",
   192  			pattern:     "",
   193  			wantErr:     true,
   194  		},
   195  		{
   196  			description: "fallback walker handles the same query when ripgrep is absent from PATH",
   197  			pattern:     "hello",
   198  			hidePath:    true,
   199  			assert: func(t *testing.T, out string) {
   200  				require.Contains(t, out, "a.txt:1:")
   201  			},
   202  		},
   203  	}
   204  
   205  	for _, tc := range tests {
   206  		t.Run(tc.description, func(t *testing.T) {
   207  			if tc.hidePath {
   208  				t.Setenv("PATH", "")
   209  			}
   210  			out, isErr := execGrep(context.Background(), mustJSON(t, map[string]any{"pattern": tc.pattern}), env)
   211  			require.Equal(t, tc.wantErr, isErr, "output=%q", out)
   212  			if tc.assert != nil {
   213  				tc.assert(t, out)
   214  			}
   215  		})
   216  	}
   217  }
   218  
   219  func TestExecGlobToleratesSymlinkLoopsInWorkdir(t *testing.T) {
   220  	work, _ := symlinkLoopFixture(t)
   221  	require.NoError(t, os.WriteFile(filepath.Join(work, "ok.txt"), []byte("x"), 0o644))
   222  
   223  	out, isErr := execGlob(context.Background(), mustJSON(t, map[string]any{"pattern": "*"}), &AgentToolContext{Workdir: work})
   224  	require.False(t, isErr, "output=%q", out)
   225  	require.Contains(t, strings.Split(out, "\n"), filepath.Join(work, "ok.txt"))
   226  }
   227  
   228  // TestExecGrepSkipsSymlinks verifies the built-in walker never reads through a
   229  // symlink: a link inside the workdir pointing at a file outside it (e.g. a
   230  // stand-in for /etc/shadow) must not have its target's contents surface in
   231  // grep output. PATH is cleared so the walker is exercised rather than ripgrep.
   232  func TestExecGrepSkipsSymlinks(t *testing.T) {
   233  	t.Setenv("PATH", "")
   234  	work := t.TempDir()
   235  	outside := t.TempDir()
   236  	env := &AgentToolContext{Workdir: work}
   237  
   238  	require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("TOPSECRET_NEEDLE"), 0o600))
   239  	require.NoError(t, os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(work, "leak")))
   240  	require.NoError(t, os.WriteFile(filepath.Join(work, "real.txt"), []byte("ordinary line"), 0o644))
   241  
   242  	out, isErr := execGrep(context.Background(), mustJSON(t, map[string]any{"pattern": "TOPSECRET_NEEDLE"}), env)
   243  	require.False(t, isErr, "output=%q", out)
   244  	require.Equal(t, "no matches", out,
   245  		"grep must not follow a symlink out of the workdir; %q leaked", out)
   246  }
   247  
   248  // TestGlobMatchDeepDoublestarBacktracking checks that a pattern alternating
   249  // "**" and a literal segment matches a deeply nested path without exponential
   250  // backtracking. Without memoization, ~30 alternating "**"/"x" segments against
   251  // a 30-deep "x/x/.../x" path is ~2^30 recursive calls; with memoization it is
   252  // O(n^2). Bound the wall time so a regression fails fast rather than hanging
   253  // the suite.
   254  func TestGlobMatchDeepDoublestarBacktracking(t *testing.T) {
   255  	const depth = 30
   256  	patSegs := make([]string, 0, depth*2)
   257  	nameSegs := make([]string, 0, depth)
   258  	for i := 0; i < depth; i++ {
   259  		patSegs = append(patSegs, "**", "x")
   260  		nameSegs = append(nameSegs, "x")
   261  	}
   262  	pat := strings.Join(patSegs, "/")
   263  	name := strings.Join(nameSegs, "/")
   264  
   265  	start := time.Now()
   266  	require.True(t, globMatch(pat, name), "pattern %q should match %q", pat, name)
   267  	// A non-match (one extra trailing literal that the path lacks) is the
   268  	// worst case for a backtracking matcher: it explores every failed branch.
   269  	require.False(t, globMatch(pat+"/y", name))
   270  	elapsed := time.Since(start)
   271  	require.Less(t, elapsed, 5*time.Second,
   272  		"globMatch took %v for a %d-deep ** pattern; backtracking is unbounded", elapsed, depth)
   273  }