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  }