You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Grep caps. These bound the cost of a single grep request to avoid a DoS via a
|
||||
// crafted pattern / huge tree. All are overridable on the handler.
|
||||
const (
|
||||
DefaultGrepMaxMatches = 200
|
||||
DefaultGrepMaxFiles = 5000
|
||||
DefaultGrepMaxFileSize = 1 << 20 // 1 MiB: skip larger files
|
||||
DefaultGrepMaxLineLen = 1000 // truncate long matched lines
|
||||
)
|
||||
|
||||
// GrepHandler handles fs/grep: a bounded regexp search under the worktree using a
|
||||
// pure-Go walk (no shell, no git grep interpolation) confined to the pathsafe
|
||||
// root. Binary files and oversize files are skipped.
|
||||
type GrepHandler struct {
|
||||
MaxMatches int
|
||||
MaxFiles int
|
||||
MaxFileSize int64
|
||||
}
|
||||
|
||||
// NewGrepHandler constructs a GrepHandler with default caps.
|
||||
func NewGrepHandler() *GrepHandler {
|
||||
return &GrepHandler{
|
||||
MaxMatches: DefaultGrepMaxMatches,
|
||||
MaxFiles: DefaultGrepMaxFiles,
|
||||
MaxFileSize: DefaultGrepMaxFileSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GrepHandler) maxMatches() int {
|
||||
if h.MaxMatches > 0 {
|
||||
return h.MaxMatches
|
||||
}
|
||||
return DefaultGrepMaxMatches
|
||||
}
|
||||
|
||||
func (h *GrepHandler) maxFiles() int {
|
||||
if h.MaxFiles > 0 {
|
||||
return h.MaxFiles
|
||||
}
|
||||
return DefaultGrepMaxFiles
|
||||
}
|
||||
|
||||
func (h *GrepHandler) maxFileSize() int64 {
|
||||
if h.MaxFileSize > 0 {
|
||||
return h.MaxFileSize
|
||||
}
|
||||
return DefaultGrepMaxFileSize
|
||||
}
|
||||
|
||||
type fsGrepParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Pattern string `json:"pattern"`
|
||||
Path string `json:"path,omitempty"` // sub-path to scope the search; defaults to cwd root
|
||||
CaseSensitive bool `json:"case_sensitive,omitempty"`
|
||||
}
|
||||
|
||||
type grepMatch struct {
|
||||
File string `json:"file"` // worktree-relative
|
||||
Line int `json:"line"` // 1-based
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type fsGrepResult struct {
|
||||
Matches []grepMatch `json:"matches"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// Handle runs a regexp search. Returns FsError for path escape (-32001), invalid
|
||||
// pattern (-32602), or read failure. Match/file caps are honored.
|
||||
func (h *GrepHandler) Handle(ctx context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult {
|
||||
var p fsGrepParams
|
||||
if err := json.Unmarshal(paramsJSON, &p); err != nil {
|
||||
return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}}
|
||||
}
|
||||
if strings.TrimSpace(p.Pattern) == "" {
|
||||
return FsResult{Err: &FsError{Code: -32602, Message: "pattern required"}}
|
||||
}
|
||||
expr := p.Pattern
|
||||
if !p.CaseSensitive {
|
||||
expr = "(?i)" + expr
|
||||
}
|
||||
re, err := regexp.Compile(expr)
|
||||
if err != nil {
|
||||
return FsResult{Err: &FsError{Code: -32602, Message: "invalid pattern: " + err.Error()}}
|
||||
}
|
||||
|
||||
searchPath := p.Path
|
||||
if searchPath == "" {
|
||||
searchPath = "."
|
||||
}
|
||||
root, err := resolveSafePath(sess.CwdPath, searchPath)
|
||||
if err != nil {
|
||||
return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}}
|
||||
}
|
||||
|
||||
cwd := filepath.Clean(sess.CwdPath)
|
||||
out := fsGrepResult{Matches: make([]grepMatch, 0, 16)}
|
||||
maxMatches := h.maxMatches()
|
||||
maxFiles := h.maxFiles()
|
||||
maxSize := h.maxFileSize()
|
||||
filesScanned := 0
|
||||
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
return nil // skip unreadable entries
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return filepath.SkipAll
|
||||
default:
|
||||
}
|
||||
if d.IsDir() {
|
||||
if isSkippedDir(d.Name()) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filesScanned >= maxFiles {
|
||||
out.Truncated = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
info, ierr := d.Info()
|
||||
if ierr != nil || info.Size() > maxSize {
|
||||
return nil
|
||||
}
|
||||
filesScanned++
|
||||
rel, rerr := filepath.Rel(cwd, path)
|
||||
if rerr != nil {
|
||||
rel = path
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if grepFile(path, rel, re, &out, maxMatches) {
|
||||
out.Truncated = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil && walkErr != filepath.SkipAll {
|
||||
return FsResult{Err: &FsError{Code: -32603, Message: "grep walk: " + walkErr.Error()}}
|
||||
}
|
||||
|
||||
res, _ := json.Marshal(out)
|
||||
return FsResult{OK: res}
|
||||
}
|
||||
|
||||
// grepFile scans one file line-by-line. Returns true when the global match cap
|
||||
// was reached (caller should stop the walk).
|
||||
func grepFile(abs, rel string, re *regexp.Regexp, out *fsGrepResult, maxMatches int) bool {
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||
lineNo := 0
|
||||
for sc.Scan() {
|
||||
lineNo++
|
||||
line := sc.Bytes()
|
||||
// Skip binary content (NUL in line).
|
||||
if hasNUL(line) {
|
||||
return false
|
||||
}
|
||||
if re.Match(line) {
|
||||
text := string(line)
|
||||
if len(text) > DefaultGrepMaxLineLen {
|
||||
text = text[:DefaultGrepMaxLineLen] + "...[truncated]"
|
||||
}
|
||||
out.Matches = append(out.Matches, grepMatch{File: rel, Line: lineNo, Text: text})
|
||||
if len(out.Matches) >= maxMatches {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasNUL(b []byte) bool {
|
||||
for _, c := range b {
|
||||
if c == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// skippedDirs are never descended into during grep.
|
||||
var skippedDirs = map[string]struct{}{
|
||||
".git": {}, "node_modules": {}, "vendor": {}, "dist": {}, "build": {},
|
||||
".venv": {}, "__pycache__": {}, "third_party": {},
|
||||
}
|
||||
|
||||
func isSkippedDir(name string) bool {
|
||||
_, ok := skippedDirs[name]
|
||||
return ok
|
||||
}
|
||||
Reference in New Issue
Block a user