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 }