Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 107 additions & 97 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -17126,6 +17126,109 @@
"funnel",
"note"
]
},
"FindOpportunitiesRequest": {
"type": "object",
"properties": {
"targets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"owner": {
"type": "string",
"minLength": 1,
"maxLength": 39
},
"repo": {
"type": "string",
"minLength": 1,
"maxLength": 100
}
},
"required": [
"owner",
"repo"
]
},
"maxItems": 25
},
"searchQuery": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"goalSpec": {
"type": "object",
"properties": {
"lane": {
"type": "string",
"minLength": 1
},
"minRankScore": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"languages": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 30
},
"maxItems": 20
}
}
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 50
}
}
},
"IssueRagRetrieveRequest": {
"type": "object",
"properties": {
"owner": {
"type": "string",
"minLength": 1,
"maxLength": 39
},
"repo": {
"type": "string",
"minLength": 1,
"maxLength": 100
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 300
},
"body": {
"type": "string",
"maxLength": 20000
},
"labels": {
"type": "array",
"items": {
"type": "string",
"maxLength": 100
},
"maxItems": 50
},
"topK": {
"type": "integer",
"minimum": 1,
"maximum": 12
}
},
"required": [
"owner",
"repo",
"title"
]
}
},
"parameters": {},
Expand Down Expand Up @@ -23601,65 +23704,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"targets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"owner": {
"type": "string",
"minLength": 1,
"maxLength": 39
},
"repo": {
"type": "string",
"minLength": 1,
"maxLength": 100
}
},
"required": [
"owner",
"repo"
]
},
"maxItems": 25
},
"searchQuery": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"goalSpec": {
"type": "object",
"properties": {
"lane": {
"type": "string",
"minLength": 1
},
"minRankScore": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"languages": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 30
},
"maxItems": 20
}
}
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 50
}
}
"$ref": "#/components/schemas/FindOpportunitiesRequest"
}
}
}
Expand Down Expand Up @@ -23696,7 +23741,8 @@
"operationId": "postOpportunitiesFind",
"tags": [
"Discovery"
]
],
"description": "Rank contribution opportunities across the given targets or search query. At least one of `targets` (non-empty) or `searchQuery` is required — JSON Schema cannot express that cross-field rule, so an empty body is rejected with reason `targets_or_search_query_required` (#10040)."
}
},
"/v1/issue-rag/retrieve": {
Expand All @@ -23706,43 +23752,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"owner": {
"type": "string",
"maxLength": 39
},
"repo": {
"type": "string",
"maxLength": 100
},
"title": {
"type": "string",
"maxLength": 300
},
"body": {
"type": "string",
"maxLength": 20000
},
"labels": {
"type": "array",
"items": {
"type": "string",
"maxLength": 100
},
"maxItems": 50
},
"topK": {
"type": "integer",
"minimum": 1,
"maximum": 12
}
},
"required": [
"owner",
"repo",
"title"
]
"$ref": "#/components/schemas/IssueRagRetrieveRequest"
}
}
}
Expand Down
8 changes: 5 additions & 3 deletions packages/loopover-contract/src/tools/local-branch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,9 +527,11 @@ export const findOpportunitiesTool = defineTool({

/** Same: the remote copy carried the bounds, the stdio copy carried none. */
export const RetrieveIssueContextInput = z.object({
owner: z.string().max(39),
repo: z.string().max(100),
title: z.string().max(PREFLIGHT_LIMITS.titleChars),
// #10040: `.min(1)` matches `ownerRepoInput` and the hand-rolled `owner_and_repo_required` /
// `title_required` checks — the catalog previously published empty strings as valid.
owner: z.string().min(1).max(39),
repo: z.string().min(1).max(100),
title: z.string().min(1).max(PREFLIGHT_LIMITS.titleChars),
body: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(),
labels: z.array(z.string().max(PREFLIGHT_LIMITS.labelChars)).max(PREFLIGHT_LIMITS.labels).optional(),
topK: z.number().int().min(1).max(12).optional(),
Expand Down
19 changes: 17 additions & 2 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ import {
} from "../services/control-panel-roles";
import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportunitiesInput } from "../mcp/find-opportunities";
import { runIssueRagRetrieval, validateIssueRagInput, type IssueRagInput } from "../mcp/issue-rag";
import { FindOpportunitiesRequestSchema, IssueRagRetrieveRequestSchema } from "../openapi/schemas";
import { buildBoundaryTestGenerationFinding, buildBoundaryTestGenerationSpec } from "../signals/boundary-test-generation";
import { buildTestEvidenceReport } from "../signals/test-evidence";
import { buildStructuralImprovementAssessment } from "../signals/improvement";
Expand Down Expand Up @@ -3592,7 +3593,13 @@ export function createApp() {
/* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific guards. */
if (!identity) return c.json({ error: "unauthorized" }, 401);
const body = await c.req.json().catch(() => null);
const parsed = validateFindOpportunitiesInput((body ?? {}) as FindOpportunitiesInput);
// #10040: same zod object the OpenAPI document publishes — refuse before the hand-rolled
// cross-field / normalisation pass, and keep the existing invalid_request body shape.
const schemaParsed = FindOpportunitiesRequestSchema.safeParse(body ?? {});
if (!schemaParsed.success) {
return c.json({ status: "invalid_request", ranked: [], totalCandidates: 0, reason: "invalid_body" }, 400);
}
const parsed = validateFindOpportunitiesInput(schemaParsed.data as FindOpportunitiesInput);
if (!parsed.ok) {
return c.json({ status: "invalid_request", ranked: [], totalCandidates: 0, reason: parsed.reason }, 400);
}
Expand All @@ -3617,7 +3624,15 @@ export function createApp() {
/* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific guards. */
if (!identity) return c.json({ error: "unauthorized" }, 401);
const body = await c.req.json().catch(() => null);
const parsed = validateIssueRagInput((body ?? {}) as IssueRagInput);
// #10040: same zod object the OpenAPI document publishes — refuse before the hand-rolled pass.
const schemaParsed = IssueRagRetrieveRequestSchema.safeParse(body ?? {});
if (!schemaParsed.success) {
return c.json(
{ status: "invalid_request", repoFullName: "", reason: "invalid_body", telemetry: { attempted: false, injected: false, retrievedPaths: [] } },
400,
);
}
const parsed = validateIssueRagInput(schemaParsed.data as IssueRagInput);
if (!parsed.ok) {
return c.json({ status: "invalid_request", repoFullName: "", reason: parsed.reason, telemetry: { attempted: false, injected: false, retrievedPaths: [] } }, 400);
}
Expand Down
21 changes: 3 additions & 18 deletions src/mcp/find-opportunities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export function normalizeFindOpportunitiesLimit(limit: number | null | undefined
export function validateFindOpportunitiesInput(
input: FindOpportunitiesInput,
): { ok: true; value: FindOpportunitiesInput } | { ok: false; reason: string } {
// #10040: bound/type checks live on FindOpportunitiesInput (and the OpenAPI request schema derived
// from it). This helper keeps only what zod cannot express: the cross-field targets/searchQuery
// rule, target de-duplication, and trimming/normalisation.
const targets = Array.isArray(input.targets) ? input.targets : undefined;
const searchQuery = typeof input.searchQuery === "string" ? input.searchQuery.trim() : "";
const hasTargets = Boolean(targets && targets.length > 0);
Expand All @@ -89,36 +92,18 @@ export function validateFindOpportunitiesInput(
}
let normalizedTargets: FindOpportunitiesTarget[] | undefined;
if (hasTargets) {
if (targets!.length > MAX_FIND_OPPORTUNITIES_TARGETS) return { ok: false, reason: "too_many_targets" };
const seenTargets = new Set<string>();
normalizedTargets = [];
for (const target of targets!) {
const owner = typeof target?.owner === "string" ? target.owner.trim() : "";
const repo = typeof target?.repo === "string" ? target.repo.trim() : "";
if (!owner || !repo) return { ok: false, reason: "invalid_target" };
if (owner.length > MAX_FIND_OPPORTUNITIES_OWNER_LENGTH) return { ok: false, reason: "owner_too_long" };
if (repo.length > MAX_FIND_OPPORTUNITIES_REPO_LENGTH) return { ok: false, reason: "repo_too_long" };
const key = `${owner.toLowerCase()}/${repo.toLowerCase()}`;
if (seenTargets.has(key)) continue;
seenTargets.add(key);
normalizedTargets.push({ owner, repo });
}
}
if (hasSearch && searchQuery.length > 500) return { ok: false, reason: "search_query_too_long" };
const languages = input.goalSpec?.languages;
if (languages !== undefined) {
if (!Array.isArray(languages) || languages.length > MAX_FIND_OPPORTUNITIES_LANGUAGES) {
return { ok: false, reason: "invalid_languages" };
}
for (const language of languages) {
const value = typeof language === "string" ? language.trim() : "";
if (!value || value.length > MAX_FIND_OPPORTUNITIES_LANGUAGE_LENGTH) return { ok: false, reason: "invalid_languages" };
}
}
const minRankScore = input.goalSpec?.minRankScore;
if (minRankScore !== undefined && (!Number.isFinite(minRankScore) || minRankScore < 0 || minRankScore > 100)) {
return { ok: false, reason: "invalid_min_rank_score" };
}
return {
ok: true,
value: {
Expand Down
37 changes: 22 additions & 15 deletions src/mcp/issue-rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,32 +26,39 @@ export type IssueRagResult = {
telemetry: IssueRagTelemetry;
};

function cleanLabels(labels: string[] | undefined): string[] | undefined {
if (!labels) return undefined;
const cleaned = labels.map((label) => label.trim()).filter(Boolean).slice(0, PREFLIGHT_LIMITS.labels);
return cleaned.length > 0 ? cleaned : undefined;
function cleanLabels(labels: string[] | undefined): { ok: true; value: string[] | undefined } | { ok: false; reason: string } {
if (!labels) return { ok: true, value: undefined };
// #10040: do not silently truncate — over-long arrays are rejected by RetrieveIssueContextInput
// before this runs; here we only trim/filter empties and enforce per-label length after trim.
const cleaned: string[] = [];
for (const label of labels) {
const value = typeof label === "string" ? label.trim() : "";
if (!value) continue;
if (value.length > PREFLIGHT_LIMITS.labelChars) return { ok: false, reason: "invalid_labels" };
cleaned.push(value);
}
return { ok: true, value: cleaned.length > 0 ? cleaned : undefined };
}

export function validateIssueRagInput(
input: IssueRagInput,
): { ok: true; value: IssueRagInput & { repoFullName: string } } | { ok: false; reason: string } {
// #10040: bound/type checks live on RetrieveIssueContextInput. This helper keeps trimming and the
// post-trim emptiness checks zod cannot see (whitespace-only owner/repo/title), and refuses
// over-long body instead of slicing it.
const owner = typeof input.owner === "string" ? input.owner.trim() : "";
const repo = typeof input.repo === "string" ? input.repo.trim() : "";
const title = typeof input.title === "string" ? input.title.trim() : "";
if (!owner || !repo) return { ok: false, reason: "owner_and_repo_required" };
if (!title) return { ok: false, reason: "title_required" };
if (owner.length > MAX_ISSUE_RAG_OWNER_LENGTH) return { ok: false, reason: "owner_too_long" };
if (repo.length > MAX_ISSUE_RAG_REPO_LENGTH) return { ok: false, reason: "repo_too_long" };
if (title.length > PREFLIGHT_LIMITS.titleChars) return { ok: false, reason: "title_too_long" };
const body = typeof input.body === "string" ? input.body.slice(0, PREFLIGHT_LIMITS.bodyChars) : undefined;
const labels = cleanLabels(input.labels);
if (labels) {
for (const label of labels) {
if (label.length > PREFLIGHT_LIMITS.labelChars) return { ok: false, reason: "invalid_labels" };
}
if (typeof input.body === "string" && input.body.length > PREFLIGHT_LIMITS.bodyChars) {
return { ok: false, reason: "body_too_long" };
}
const body = typeof input.body === "string" ? input.body : undefined;
const labelsResult = cleanLabels(input.labels);
if (!labelsResult.ok) return labelsResult;
const topK = input.topK;
if (topK !== undefined && (!Number.isFinite(topK) || topK < 1 || topK > 12)) {
if (topK !== undefined && (!Number.isFinite(topK) || topK < 1 || topK > 12 || !Number.isInteger(topK))) {
return { ok: false, reason: "invalid_top_k" };
}
return {
Expand All @@ -61,7 +68,7 @@ export function validateIssueRagInput(
repo,
title,
...(body !== undefined ? { body } : {}),
...(labels ? { labels } : {}),
...(labelsResult.value ? { labels: labelsResult.value } : {}),
...(topK !== undefined ? { topK: normalizeIssueRagTopK(topK) } : {}),
repoFullName: `${owner}/${repo}`,
},
Expand Down
Loading
Loading