fix(agentctl): clamp file-search limit to bound result allocation (#2088)

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-31 09:31:37 +01:00
committed by GitHub
parent 5b1d464ae5
commit 6c01e4e1ae
2 changed files with 60 additions and 1 deletions
@@ -876,6 +876,19 @@ type fileSearchCandidate struct {
repositoryName string
}
const (
// fileSearchDefaultLimit applies when the caller passes a non-positive limit.
fileSearchDefaultLimit = 20
// fileSearchMaxLimit caps the caller-supplied limit before it is used to
// pre-allocate the result slice. The limit reaches this code straight from
// the `limit` query parameter of GET /api/v1/workspace/search, so without a
// ceiling a request like `?limit=2000000000` makes the process reserve
// gigabytes for a result set that can never exceed the number of tracked
// files. Mirrors the clamp validateContentSearch already applies to
// content search.
fileSearchMaxLimit = 200
)
// GetFileContentAtRef returns the content of a file at a specific git ref (branch, commit, HEAD, etc).
// If the file is not valid UTF-8, it is base64-encoded and isBinary is true.
func (wt *WorkspaceTracker) GetFileContentAtRef(ctx context.Context, reqPath string, ref string) (string, int64, bool, error) {
@@ -1007,7 +1020,10 @@ func searchFileCandidates(
return []types.FileSearchResult{}
}
if limit <= 0 {
limit = 20
limit = fileSearchDefaultLimit
}
if limit > fileSearchMaxLimit {
limit = fileSearchMaxLimit
}
query = strings.ToLower(query)
@@ -2,6 +2,8 @@ package process
import (
"context"
"fmt"
"math"
"os"
"path/filepath"
"runtime"
@@ -517,6 +519,47 @@ func TestSearchFileCandidatesBreaksTiesByRepositoryRelativePathLength(t *testing
}
}
// A caller-supplied limit reaches searchFileCandidates straight from the
// `limit` query parameter, so an absurd value must not drive the result
// slice's pre-allocation.
func TestSearchFileCandidatesClampsCallerSuppliedLimit(t *testing.T) {
candidates := make([]fileSearchCandidate, 0, fileSearchMaxLimit+50)
for i := range fileSearchMaxLimit + 50 {
candidates = append(candidates, fileSearchCandidate{
path: fmt.Sprintf("src/query-%d.go", i),
matchPath: fmt.Sprintf("src/query-%d.go", i),
})
}
results := searchFileCandidates(candidates, "query", math.MaxInt32)
if len(results) != fileSearchMaxLimit {
t.Fatalf("len(results) = %d, want the clamped %d", len(results), fileSearchMaxLimit)
}
if cap(results) > fileSearchMaxLimit {
t.Fatalf("cap(results) = %d, want no more than the clamped %d", cap(results), fileSearchMaxLimit)
}
}
func TestSearchFileCandidatesDefaultsNonPositiveLimit(t *testing.T) {
candidates := make([]fileSearchCandidate, 0, fileSearchDefaultLimit+5)
for i := range fileSearchDefaultLimit + 5 {
candidates = append(candidates, fileSearchCandidate{
path: fmt.Sprintf("src/query-%d.go", i),
matchPath: fmt.Sprintf("src/query-%d.go", i),
})
}
for _, limit := range []int{0, -1} {
results := searchFileCandidates(candidates, "query", limit)
if len(results) != fileSearchDefaultLimit {
t.Fatalf("limit %d: len(results) = %d, want the default %d",
limit, len(results), fileSearchDefaultLimit)
}
}
}
func TestGetFileTree_Symlinks(t *testing.T) {
tmpDir := t.TempDir()
wt := &WorkspaceTracker{workDir: tmpDir}