From 1afc96ea41ffc114d0b7bf6e60a1c7ae9ce75260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sat, 8 Aug 2026 19:38:34 +0200 Subject: [PATCH] fix(agentctl): size file-search results from trusted bounds (#2434) Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 5 (1M context) --- .../server/process/workspace_files.go | 21 +++++++++++-------- .../server/process/workspace_files_test.go | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index 079a2c8a3a..efd8c17192 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -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, diff --git a/apps/backend/internal/agentctl/server/process/workspace_files_test.go b/apps/backend/internal/agentctl/server/process/workspace_files_test.go index cf37ff9706..df33257bdb 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files_test.go @@ -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 {