github.com/anthropics/anthropic-sdk-go@v1.70.1/tools/agenttoolset/search.go (about) 1 package agenttoolset 2 3 import ( 4 "bytes" 5 "cmp" 6 "context" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "io/fs" 11 "os" 12 "os/exec" 13 "path/filepath" 14 "regexp" 15 "sort" 16 "strings" 17 18 anthropic "github.com/anthropics/anthropic-sdk-go" 19 ) 20 21 const ( 22 globResultLimit = 200 23 grepOutputLimit = 100 * 1024 24 grepMaxFileBytes = 8 * 1024 * 1024 25 walkMaxEntries = 50_000 26 ) 27 28 const truncationNotice = "[output truncated]" 29 30 // BetaGlobTool returns an anthropic.BetaTool that globs under env.Workdir or 31 // an allowed root. 32 func BetaGlobTool(env *AgentToolContext) anthropic.BetaTool { 33 return &funcTool{ 34 name: "glob", 35 description: "List paths matching a glob pattern (e.g. **/*.go), newest first.", 36 schema: objectSchema(map[string]any{ 37 "pattern": prop("string", "Glob pattern, e.g. **/*.go (** matches any depth)."), 38 "path": prop("string", "Directory to search in. Defaults to the workdir."), 39 }, "pattern"), 40 env: env, 41 run: execGlob, 42 } 43 } 44 45 // BetaGrepTool returns an anthropic.BetaTool that searches file contents under 46 // env.Workdir or an allowed root. 47 func BetaGrepTool(env *AgentToolContext) anthropic.BetaTool { 48 return &funcTool{ 49 name: "grep", 50 description: "Search file contents for a regex. Uses ripgrep if available, otherwise a built-in walker.", 51 schema: objectSchema(map[string]any{ 52 "pattern": prop("string", "Regular expression to search for."), 53 "path": prop("string", "Directory to search in. Defaults to the workdir."), 54 }, "pattern"), 55 env: env, 56 run: execGrep, 57 } 58 } 59 60 func execGlob(_ context.Context, raw json.RawMessage, env *AgentToolContext) (string, bool) { 61 var in anthropic.BetaManagedAgentsAgentToolset20260401GlobInput 62 if err := json.Unmarshal(raw, &in); err != nil { 63 return errorf("invalid glob input: %v", err) 64 } 65 if in.Pattern == "" { 66 return errorf("glob: pattern is required") 67 } 68 69 if filepath.IsAbs(in.Pattern) { 70 return errorf("glob: absolute pattern not permitted; pass a relative pattern (and optionally path)") 71 } 72 // Reject a ".." segment in the pattern itself. The WalkDir below is rooted 73 // at the (confined) root and matches against paths relative to it, so a 74 // "../.." pattern matches nothing today — but rejecting it outright keeps 75 // the confinement explicit and consistent with the other SDKs' glob tools, 76 // which feed the pattern to a filesystem globber where ".." would escape 77 // the workdir. 78 if hasParentDirSegment(in.Pattern) { 79 return errorf("glob: pattern %q must not contain a %q segment", in.Pattern, "..") 80 } 81 root, err := resolvePath(env, cmp.Or(in.Path, ".")) 82 if err != nil { 83 return errorf("glob: %v", err) 84 } 85 86 type entry struct { 87 path string 88 mtime int64 89 } 90 // Walk the tree ourselves (stdlib only — no third-party glob dependency) 91 // and match each entry against the pattern. filepath.WalkDir never 92 // follows symlinks, so the walk cannot escape root. We stop after 93 // walkMaxEntries visited so a pattern over an enormous tree can't stall 94 // the runner. 95 var entries []entry 96 visited := 0 97 errStop := errors.New("stop") 98 err = filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { 99 if walkErr != nil { 100 return nil 101 } 102 if p == root { 103 return nil 104 } 105 visited++ 106 if visited > walkMaxEntries { 107 return errStop 108 } 109 rel, relErr := filepath.Rel(root, p) 110 if relErr != nil { 111 return nil 112 } 113 if !globMatch(in.Pattern, filepath.ToSlash(rel)) { 114 return nil 115 } 116 var mt int64 117 if info, statErr := d.Info(); statErr == nil { 118 mt = info.ModTime().UnixNano() 119 } 120 entries = append(entries, entry{path: p, mtime: mt}) 121 return nil 122 }) 123 if err != nil && !errors.Is(err, errStop) { 124 return errorf("glob: %v", err) 125 } 126 if len(entries) == 0 { 127 return "no matches", false 128 } 129 130 sort.Slice(entries, func(i, j int) bool { return entries[i].mtime > entries[j].mtime }) 131 if len(entries) > globResultLimit { 132 entries = entries[:globResultLimit] 133 } 134 out := make([]string, len(entries)) 135 for i, e := range entries { 136 out[i] = e.path 137 } 138 return strings.Join(out, "\n"), false 139 } 140 141 // hasParentDirSegment reports whether pattern contains a ".." path segment, 142 // which could escape the workdir when a pattern is matched against the 143 // filesystem. 144 func hasParentDirSegment(pattern string) bool { 145 for _, seg := range strings.Split(filepath.ToSlash(pattern), "/") { 146 if seg == ".." { 147 return true 148 } 149 } 150 return false 151 } 152 153 // globMatch reports whether rel — a slash-separated path relative to the search 154 // root — matches pattern. A "**" segment matches any number of path segments 155 // (including none); every other segment is matched against a single path 156 // segment with [filepath.Match] semantics (* ? [..]). This is the small subset 157 // of doublestar behaviour the glob tool actually documents, implemented with 158 // the standard library so the SDK carries no third-party glob dependency. 159 func globMatch(pattern, rel string) bool { 160 return matchGlobSegments(strings.Split(pattern, "/"), strings.Split(rel, "/")) 161 } 162 163 // matchGlobSegments matches pat against name, treating "**" as zero or more 164 // path segments; memoized to avoid exponential backtracking. 165 func matchGlobSegments(pat, name []string) bool { 166 return matchGlobSegmentsMemo(pat, name, make(map[[2]int]bool)) 167 } 168 169 func matchGlobSegmentsMemo(pat, name []string, memo map[[2]int]bool) bool { 170 key := [2]int{len(pat), len(name)} 171 if v, ok := memo[key]; ok { 172 return v 173 } 174 res := func() bool { 175 for len(pat) > 0 { 176 if pat[0] == "**" { 177 // Collapse consecutive "**" segments — they are equivalent. 178 for len(pat) > 0 && pat[0] == "**" { 179 pat = pat[1:] 180 } 181 if len(pat) == 0 { 182 return true // trailing "**" matches whatever remains 183 } 184 // Try matching the rest of the pattern at every suffix of name. 185 for i := 0; i <= len(name); i++ { 186 if matchGlobSegmentsMemo(pat, name[i:], memo) { 187 return true 188 } 189 } 190 return false 191 } 192 if len(name) == 0 { 193 return false 194 } 195 ok, err := filepath.Match(pat[0], name[0]) 196 if err != nil || !ok { 197 return false 198 } 199 pat, name = pat[1:], name[1:] 200 } 201 return len(name) == 0 202 }() 203 memo[key] = res 204 return res 205 } 206 207 func execGrep(ctx context.Context, raw json.RawMessage, env *AgentToolContext) (string, bool) { 208 var in anthropic.BetaManagedAgentsAgentToolset20260401GrepInput 209 if err := json.Unmarshal(raw, &in); err != nil { 210 return errorf("invalid grep input: %v", err) 211 } 212 if in.Pattern == "" { 213 return errorf("grep: pattern is required") 214 } 215 searchPath, err := resolvePath(env, cmp.Or(in.Path, ".")) 216 if err != nil { 217 return errorf("grep: %v", err) 218 } 219 220 if rg, err := exec.LookPath("rg"); err == nil { 221 return runRipgrep(ctx, rg, in.Pattern, searchPath) 222 } 223 return runWalkGrep(in.Pattern, searchPath) 224 } 225 226 func runRipgrep(ctx context.Context, rg, pattern, path string) (string, bool) { 227 // --max-filesize bounds per-file size; cappedBuffer bounds total stdout. 228 cmd := exec.CommandContext(ctx, rg, "-n", "--no-heading", 229 "--max-filesize", fmt.Sprintf("%d", grepMaxFileBytes), 230 "-e", pattern, "--", path) 231 stdout := newCappedBuffer(grepOutputLimit) 232 var stderr bytes.Buffer 233 cmd.Stdout = stdout 234 cmd.Stderr = &stderr 235 err := cmd.Run() 236 var exitErr *exec.ExitError 237 if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { 238 return "no matches", false 239 } 240 if err != nil { 241 return errorf("grep: rg failed: %v: %s", err, stderr.String()) 242 } 243 out := stdout.String() 244 if stdout.truncated { 245 out += "\n" + truncationNotice 246 } 247 return out, false 248 } 249 250 // cappedBuffer is an io.Writer that accumulates at most cap bytes and 251 // discards the rest. 252 type cappedBuffer struct { 253 buf bytes.Buffer 254 cap int 255 truncated bool 256 } 257 258 func newCappedBuffer(capacity int) *cappedBuffer { return &cappedBuffer{cap: capacity} } 259 260 func (w *cappedBuffer) Write(p []byte) (int, error) { 261 remaining := w.cap - w.buf.Len() 262 if remaining <= 0 { 263 w.truncated = true 264 return len(p), nil // pretend we consumed it so the producer keeps going 265 } 266 if len(p) > remaining { 267 w.buf.Write(p[:remaining]) 268 w.truncated = true 269 return len(p), nil 270 } 271 return w.buf.Write(p) 272 } 273 274 func (w *cappedBuffer) String() string { return w.buf.String() } 275 276 func runWalkGrep(pattern, root string) (string, bool) { 277 re, err := regexp.Compile(pattern) 278 if err != nil { 279 return errorf("grep: invalid regex: %v", err) 280 } 281 var hits []string 282 budget := grepOutputLimit 283 push := func(line string) bool { 284 budget -= len(line) + 1 285 if budget < 0 { 286 hits = append(hits, truncationNotice) 287 return false 288 } 289 hits = append(hits, line) 290 return true 291 } 292 seen := 0 293 errStop := errors.New("stop") 294 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { 295 if walkErr != nil { 296 return nil 297 } 298 // Skip symlinks entirely: a symlink inside the workdir pointing at, 299 // say, /etc/shadow must not be read (or descended into) by grep. 300 // WalkDir never follows symlinks for traversal, but it still hands 301 // the link entry to the callback as a non-dir, so os.ReadFile below 302 // would follow it. Only descend real dirs, only read real files. 303 if d.Type()&fs.ModeSymlink != 0 { 304 return nil 305 } 306 if d.IsDir() { 307 if d.Name() == ".git" || d.Name() == "node_modules" { 308 return filepath.SkipDir 309 } 310 return nil 311 } 312 if !d.Type().IsRegular() { 313 return nil // skip FIFOs, devices, sockets 314 } 315 seen++ 316 if seen > walkMaxEntries { 317 return errStop 318 } 319 // Stat before reading so a multi-GB file can't OOM the runner. Go's 320 // regexp is RE2 (linear-time) so there is no ReDoS line-length concern. 321 if info, err := d.Info(); err != nil || info.Size() > grepMaxFileBytes { 322 return nil 323 } 324 data, err := os.ReadFile(path) 325 if err != nil { 326 return nil 327 } 328 sniff := min(len(data), 512) 329 if bytes.IndexByte(data[:sniff], 0) >= 0 { 330 return nil 331 } 332 for i, line := range strings.Split(string(data), "\n") { 333 if re.MatchString(line) && !push(fmt.Sprintf("%s:%d:%s", path, i+1, line)) { 334 return errStop 335 } 336 } 337 return nil 338 }) 339 if len(hits) == 0 { 340 return "no matches", false 341 } 342 return strings.Join(hits, "\n"), false 343 }