// This file implements the search tools (US-019): grep (search file contents by // regexp with optional glob filtering), find (locate files by name glob), and ls // (list a directory, distinguishing files from directories). All three resolve // paths against a Root with the same boundary guard as the other tools and skip // paths ignored by the workspace .gitignore. They are read-only → parallel. package agenttool import ( "bufio" "context" "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "github.com/smallnest/pigo/internal/agentcore" ) // searchMaxResults caps the number of matches/entries any single search returns // so a broad query cannot flood the model's context. const searchMaxResults = 1000 // resolveWithin resolves p against root and verifies it stays within it. It is // the single workspace-boundary policy shared by every file tool: the search // tools call it directly, and ReadTool/WriteTool/EditTool.resolvePath delegate // to it, so the path-traversal guard lives in exactly one place. func resolveWithin(root, p string) (string, error) { if root == "" { wd, err := os.Getwd() if err != nil { return "", fmt.Errorf("cannot determine working directory: %w", err) } root = wd } absRoot, err := filepath.Abs(root) if err != nil { return "", fmt.Errorf("invalid root: %w", err) } var full string if filepath.IsAbs(p) { full = filepath.Clean(p) } else { full = filepath.Join(absRoot, p) } rel, err := filepath.Rel(absRoot, full) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", fmt.Errorf("path %q is outside the workspace root", p) } return full, nil } // resolveWithinAny resolves p against the first root that contains it, trying // roots in order. It exists so the file tools can additionally permit trusted // out-of-workspace roots (the skills directory) whose absolute SKILL.md paths // pigo itself advertises in the system prompt: without this, the workspace guard // would reject the very paths the model is instructed to read or author. Empty // roots are skipped; if none contain p, the standard workspace-escape error is // returned. func resolveWithinAny(roots []string, p string) (string, error) { var lastErr error for _, root := range roots { if root == "" { continue } full, err := resolveWithin(root, p) if err == nil { return full, nil } lastErr = err } if lastErr != nil { return "", lastErr } // No usable roots supplied: fall back to the default (cwd) policy. return resolveWithin("", p) } // gitignore is a minimal .gitignore matcher. It supports the common subset: // blank lines and #-comments are skipped; a leading "/" anchors to the root; // a trailing "/" matches directories only; "!" negation re-includes; and plain // patterns match by base name or path via filepath.Match. It is intentionally // not a full gitignore implementation (no "**" spanning, no nested .gitignore). type gitignore struct { rules []ignoreRule // hasSegmentRule is true when at least one rule matches by path segment // (non-anchored, no "/"). Only then does ignored() need to split relPath into // segments, so the common all-anchored case skips the split entirely. hasSegmentRule bool } type ignoreRule struct { pattern string negate bool dirOnly bool anchored bool // matchFull is precomputed at load time: an anchored pattern, or one that // contains a "/", matches against the full relative path; otherwise the rule // matches by base name or any single path segment. Hoisting this out of the // per-file loop avoids a strings.Contains scan for every file × rule. matchFull bool } // loadGitignore reads root/.gitignore. A missing file yields an empty matcher // (matches nothing), never an error. func loadGitignore(root string) *gitignore { gi := &gitignore{} data, err := os.ReadFile(filepath.Join(root, ".gitignore")) if err != nil { return gi } sc := bufio.NewScanner(strings.NewReader(string(data))) for sc.Scan() { line := strings.TrimRight(sc.Text(), " ") if line == "" || strings.HasPrefix(line, "#") { continue } r := ignoreRule{} if strings.HasPrefix(line, "!") { r.negate = true line = line[1:] } if strings.HasSuffix(line, "/") { r.dirOnly = true line = strings.TrimSuffix(line, "/") } if strings.HasPrefix(line, "/") { r.anchored = true line = strings.TrimPrefix(line, "/") } if line == "" { continue } r.pattern = line r.matchFull = r.anchored || strings.Contains(line, "/") if !r.matchFull { gi.hasSegmentRule = true } gi.rules = append(gi.rules, r) } return gi } // ignored reports whether relPath (slash-separated, relative to root) is ignored. // isDir refines dir-only rules. Later rules win, so a negation can re-include. // // The relPath is split into segments at most once per call (only when a // segment-matching rule exists), rather than re-splitting inside the rule loop: // this keeps the per-file cost O(rules) instead of O(rules × pathSegments), // which matters because ignored() is called for every entry of a WalkDir. func (g *gitignore) ignored(relPath string, isDir bool) bool { relPath = filepath.ToSlash(relPath) base := relPath if i := strings.LastIndex(relPath, "/"); i >= 0 { base = relPath[i+1:] } var segs []string if g.hasSegmentRule { segs = strings.Split(relPath, "/") } result := false for _, r := range g.rules { if r.dirOnly && !isDir { continue } var match bool if r.matchFull { match, _ = filepath.Match(r.pattern, relPath) } else { match, _ = filepath.Match(r.pattern, base) if !match { // A non-anchored pattern also matches any path component, // so an ignored directory hides everything beneath it. for _, seg := range segs { if ok, _ := filepath.Match(r.pattern, seg); ok { match = true break } } } } if match { result = !r.negate } } return result } // GrepTool searches file contents by regexp under Root, honoring .gitignore. type GrepTool struct { // Root bounds the search; empty defaults to the current working directory. Root string } type grepToolArgs struct { // Pattern is the regexp to search for (Go regexp syntax). Pattern string `json:"pattern"` // Path optionally scopes the search to a subdirectory (relative to Root). Path string `json:"path,omitempty"` // Glob optionally filters files by base-name glob (e.g. "*.go"). Glob string `json:"glob,omitempty"` } func (t *GrepTool) Name() string { return "grep" } func (t *GrepTool) Description() string { return "Search file contents by regular expression under the workspace, " + "optionally filtering files by glob. Skips .gitignore'd paths." } func (t *GrepTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionParallel } func (t *GrepTool) Schema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "pattern": {"type": "string", "description": "Regular expression to search for."}, "path": {"type": "string", "description": "Subdirectory to scope the search to (relative to the workspace root)."}, "glob": {"type": "string", "description": "Filter files by base-name glob, e.g. *.go."} }, "required": ["pattern"], "additionalProperties": false }`) } func (t *GrepTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { a, bad := decodeArgs[grepToolArgs](args, "grep") if bad != nil { return *bad, nil } if a.Pattern == "" { return errorResult("grep: pattern is required"), nil } re, err := regexp.Compile(a.Pattern) if err != nil { return errorResult(fmt.Sprintf("grep: invalid pattern: %v", err)), nil } root, err := resolveWithin(t.Root, "") if err != nil { return errorResult("grep: " + err.Error()), nil } start := root if a.Path != "" { if start, err = resolveWithin(t.Root, a.Path); err != nil { return errorResult("grep: " + err.Error()), nil } } gi := loadGitignore(root) var matches []string count := 0 walkErr := filepath.WalkDir(start, func(path string, d os.DirEntry, err error) error { if err != nil { return nil // skip unreadable entries } rel, _ := filepath.Rel(root, path) if rel == "." { return nil } if rel == ".git" || strings.HasPrefix(rel, ".git"+string(filepath.Separator)) { if d.IsDir() { return filepath.SkipDir } return nil } if gi.ignored(rel, d.IsDir()) { if d.IsDir() { return filepath.SkipDir } return nil } if d.IsDir() { return nil } if a.Glob != "" { if ok, _ := filepath.Match(a.Glob, d.Name()); !ok { return nil } } f, err := os.Open(path) if err != nil { return nil } defer f.Close() sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 0, scanBufInit), grepScanBufMax) lineNo := 0 for sc.Scan() { lineNo++ line := sc.Text() if re.MatchString(line) { matches = append(matches, fmt.Sprintf("%s:%d:%s", rel, lineNo, line)) count++ if count >= searchMaxResults { return filepath.SkipAll } } } return nil }) if walkErr != nil { return errorResult(fmt.Sprintf("grep: %v", walkErr)), nil } msg := fmt.Sprintf("%d match(es) for %q", len(matches), a.Pattern) if len(matches) > 0 { msg += "\n" + strings.Join(matches, "\n") } if count >= searchMaxResults { msg += fmt.Sprintf("\n[truncated at %d matches]", searchMaxResults) } return agentcore.AgentToolResult{ Content: agentcore.ContentList{agentcore.NewTextContent(msg)}, Details: map[string]any{"matches": len(matches)}, }, nil } // FindTool locates files by base-name glob under Root, honoring .gitignore. type FindTool struct { // Root bounds the search; empty defaults to the current working directory. Root string } type findToolArgs struct { // Glob is the base-name glob to match (e.g. "*.go"). Glob string `json:"glob"` // Path optionally scopes the search to a subdirectory (relative to Root). Path string `json:"path,omitempty"` } func (t *FindTool) Name() string { return "find" } func (t *FindTool) Description() string { return "Find files by base-name glob under the workspace. Skips .gitignore'd paths." } func (t *FindTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionParallel } func (t *FindTool) Schema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "glob": {"type": "string", "description": "Base-name glob to match, e.g. *.go."}, "path": {"type": "string", "description": "Subdirectory to scope the search to (relative to the workspace root)."} }, "required": ["glob"], "additionalProperties": false }`) } func (t *FindTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { a, bad := decodeArgs[findToolArgs](args, "find") if bad != nil { return *bad, nil } if a.Glob == "" { return errorResult("find: glob is required"), nil } root, err := resolveWithin(t.Root, "") if err != nil { return errorResult("find: " + err.Error()), nil } start := root if a.Path != "" { if start, err = resolveWithin(t.Root, a.Path); err != nil { return errorResult("find: " + err.Error()), nil } } gi := loadGitignore(root) var found []string walkErr := filepath.WalkDir(start, func(path string, d os.DirEntry, err error) error { if err != nil { return nil } rel, _ := filepath.Rel(root, path) if rel == "." { return nil } if rel == ".git" || strings.HasPrefix(rel, ".git"+string(filepath.Separator)) { if d.IsDir() { return filepath.SkipDir } return nil } if gi.ignored(rel, d.IsDir()) { if d.IsDir() { return filepath.SkipDir } return nil } if d.IsDir() { return nil } if ok, _ := filepath.Match(a.Glob, d.Name()); ok { found = append(found, rel) if len(found) >= searchMaxResults { return filepath.SkipAll } } return nil }) if walkErr != nil { return errorResult(fmt.Sprintf("find: %v", walkErr)), nil } sort.Strings(found) msg := fmt.Sprintf("%d file(s) matching %q", len(found), a.Glob) if len(found) > 0 { msg += "\n" + strings.Join(found, "\n") } return agentcore.AgentToolResult{ Content: agentcore.ContentList{agentcore.NewTextContent(msg)}, Details: map[string]any{"count": len(found)}, }, nil } // LsTool lists the entries of a directory, distinguishing files from directories. type LsTool struct { // Root bounds the listing; empty defaults to the current working directory. Root string } type lsToolArgs struct { // Path is the directory to list, relative to Root (empty = Root itself). Path string `json:"path,omitempty"` } func (t *LsTool) Name() string { return "ls" } func (t *LsTool) Description() string { return "List a directory's entries, marking directories with a trailing slash." } func (t *LsTool) ExecutionMode() agentcore.ToolExecutionMode { return agentcore.ToolExecutionParallel } func (t *LsTool) Schema() json.RawMessage { return json.RawMessage(`{ "type": "object", "properties": { "path": {"type": "string", "description": "Directory to list, relative to the workspace root."} }, "additionalProperties": false }`) } func (t *LsTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { a, bad := decodeArgs[lsToolArgs](args, "ls") if bad != nil { return *bad, nil } full, err := resolveWithin(t.Root, a.Path) if err != nil { return errorResult("ls: " + err.Error()), nil } info, err := os.Stat(full) if err != nil { if os.IsNotExist(err) { return errorResult(fmt.Sprintf("ls: %q does not exist", a.Path)), nil } return errorResult(fmt.Sprintf("ls: cannot stat %q: %v", a.Path, err)), nil } if !info.IsDir() { return errorResult(fmt.Sprintf("ls: %q is not a directory", a.Path)), nil } entries, err := os.ReadDir(full) if err != nil { return errorResult(fmt.Sprintf("ls: cannot read %q: %v", a.Path, err)), nil } var dirs, files []string for _, e := range entries { if e.IsDir() { dirs = append(dirs, e.Name()+"/") } else { files = append(files, e.Name()) } } sort.Strings(dirs) sort.Strings(files) lines := append(dirs, files...) label := a.Path if label == "" { label = "." } msg := fmt.Sprintf("%s (%d dir(s), %d file(s))", label, len(dirs), len(files)) if len(lines) > 0 { msg += "\n" + strings.Join(lines, "\n") } return agentcore.AgentToolResult{ Content: agentcore.ContentList{agentcore.NewTextContent(msg)}, Details: map[string]any{"dirs": len(dirs), "files": len(files)}, }, nil }