fix(agentctl): size file-search results from trusted bounds (#2434)

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-08-08 19:38:34 +02:00
committed by GitHub
parent 70e6eb8895
commit 1afc96ea41
2 changed files with 33 additions and 9 deletions
@@ -893,13 +893,13 @@ type fileSearchCandidate struct {
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 caps how many results one search may return. 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` would ask for a result set that can never exceed the
// number of tracked files. It also bounds the result slice's capacity,
// which is sized from this cap rather than from the request. Mirrors the
// clamp validateContentSearch already applies to content search.
fileSearchMaxLimit = 200
)
@@ -1100,8 +1100,11 @@ func searchFileCandidates(
return len(matches[i].matchPath) < len(matches[j].matchPath)
})
// Return top limit results
result := make([]types.FileSearchResult, 0, limit)
// Return top limit results. The capacity is derived from values we own —
// the matches we actually collected and the hard cap — instead of the
// caller-supplied limit, so the allocation size never depends on request
// input even indirectly.
result := make([]types.FileSearchResult, 0, min(len(matches), fileSearchMaxLimit))
for i := 0; i < len(matches) && i < limit; i++ {
result = append(result, types.FileSearchResult{
RepositoryName: matches[i].repositoryName,
@@ -541,6 +541,27 @@ func TestSearchFileCandidatesClampsCallerSuppliedLimit(t *testing.T) {
}
}
// The result slice must be sized from the matches we collected, never from the
// caller-supplied limit, so a workspace with a handful of files cannot be made
// to reserve a large slice by a single request.
func TestSearchFileCandidatesSizesResultFromMatchCount(t *testing.T) {
candidates := []fileSearchCandidate{
{path: "src/query-a.go", matchPath: "src/query-a.go"},
{path: "src/query-b.go", matchPath: "src/query-b.go"},
{path: "src/unrelated.go", matchPath: "src/unrelated.go"},
}
results := searchFileCandidates(candidates, "query", math.MaxInt32)
if len(results) != 2 {
t.Fatalf("len(results) = %d, want the 2 matching candidates", len(results))
}
if cap(results) > len(candidates) {
t.Fatalf("cap(results) = %d, want no more than the %d candidates searched",
cap(results), len(candidates))
}
}
func TestSearchFileCandidatesDefaultsNonPositiveLimit(t *testing.T) {
candidates := make([]fileSearchCandidate, 0, fileSearchDefaultLimit+5)
for i := range fileSearchDefaultLimit + 5 {