diff --git a/src/api/routes.ts b/src/api/routes.ts index 9695589359..19cbe296b2 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -221,7 +221,7 @@ import { buildRepoOutcomeCalibration } from "../services/outcome-calibration"; import { loadGatePrecisionReport } from "../services/gate-precision"; import { buildMaintainerQualityDashboard, isMaintainerQualityDataStale } from "../services/maintainer-quality-dashboard"; import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics"; -import { compileFocusManifestPolicy } from "../signals/focus-manifest"; +import { compileFocusManifestPolicy, MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest"; import { loadPublicRepoFocusManifest, loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack"; import { generateContributorIssueDrafts } from "../services/contributor-issue-draft"; @@ -302,6 +302,7 @@ async function recordRouteProductUsage( }).catch(() => undefined); } +const LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES = 1024 * 1024; const QUEUE_INTELLIGENCE_MAX_BODY_BYTES = 1024 * 1024; const QUEUE_INTELLIGENCE_MAX_PULL_REQUESTS = 250; const QUEUE_INTELLIGENCE_MAX_AUTHOR_LENGTH = 100; @@ -316,6 +317,14 @@ function parsePositiveInt(value: string | null | undefined): number | null { return parsed; } +function isJsonByteLengthWithinLimit(value: unknown, maxBytes: number): boolean { + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes; + } catch { + return false; + } +} + async function readRequestBodyWithLimit(request: Request, maxBytes: number): Promise { const stream = request.body; if (!stream) return ""; @@ -473,6 +482,12 @@ const branchEligibilitySchema = z .strict() .transform((value) => ({ ...value, status: value.status === "eligible" ? ("unknown" as const) : value.status, source: "user_supplied" as const })); +const focusManifestInputSchema = z + .record(z.string(), z.unknown()) + .refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), { + message: `focusManifest must serialize to ${MAX_FOCUS_MANIFEST_BYTES} bytes or fewer`, + }); + const localBranchAnalysisSchema = z .object({ login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), @@ -500,7 +515,7 @@ const localBranchAnalysisSchema = z scenarioNotes: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), pendingCommitCount: z.number().int().min(0).optional(), ciStatusHints: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), - focusManifest: z.record(z.string(), z.unknown()).optional(), + focusManifest: focusManifestInputSchema.optional(), branchEligibility: branchEligibilitySchema.optional(), }) .strict(); @@ -2467,7 +2482,18 @@ export function createApp() { }); app.post("/v1/local/branch-analysis", async (c) => { - const body = await c.req.json().catch(() => null); + const contentLength = parsePositiveInt(c.req.header("content-length")); + if (contentLength !== null && contentLength > LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES) { + return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413); + } + const rawBody = await readRequestBodyWithLimit(c.req.raw, LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES); + if (rawBody === null) return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + body = null; + } const parsed = localBranchAnalysisSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_local_branch_analysis_request", issues: parsed.error.issues }, 400); const unauthorized = await requireContributorAccess(c, parsed.data.login); @@ -2542,7 +2568,18 @@ export function createApp() { }); app.post("/v1/local/remediation-plan", async (c) => { - const body = await c.req.json().catch(() => null); + const contentLength = parsePositiveInt(c.req.header("content-length")); + if (contentLength !== null && contentLength > LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES) { + return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413); + } + const rawBody = await readRequestBodyWithLimit(c.req.raw, LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES); + if (rawBody === null) return c.json({ error: "payload_too_large", maxBytes: LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES }, 413); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + body = null; + } const parsed = localBranchAnalysisSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_local_branch_analysis_request", issues: parsed.error.issues }, 400); const unauthorized = await requireContributorAccess(c, parsed.data.login); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5c46243ebc..89db6a8752 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -105,6 +105,7 @@ import { import { applyStepResult, buildPlanDag, nextReadySteps, planProgress, validatePlanDag, type PlanDag } from "../services/plan-dag"; import { isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution"; import { AGENT_ACTION_CLASSES, isActingAutonomyLevel, resolveAutonomy } from "../settings/autonomy"; +import { MAX_FOCUS_MANIFEST_BYTES } from "../signals/focus-manifest"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildPredictedGateVerdict } from "../rules/predicted-gate"; import { buildIssueSlopAssessment, buildSlopAssessment, ISSUE_SLOP_RUBRIC_MARKDOWN, SLOP_RUBRIC_MARKDOWN } from "../signals/slop"; @@ -368,6 +369,20 @@ const automationStateOutputSchema = { pendingActionCount: z.number().optional(), }; +const focusManifestInputSchema = z + .record(z.string(), z.unknown()) + .refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), { + message: `focusManifest must serialize to ${MAX_FOCUS_MANIFEST_BYTES} bytes or fewer`, + }); + +function isJsonByteLengthWithinLimit(value: unknown, maxBytes: number): boolean { + try { + return new TextEncoder().encode(JSON.stringify(value)).byteLength <= maxBytes; + } catch { + return false; + } +} + const localBranchAnalysisShape = { login: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS), repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), @@ -391,7 +406,7 @@ const localBranchAnalysisShape = { expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), projectedCredibility: z.number().min(0).max(1).optional(), scenarioNotes: z.array(z.string()).max(20).optional(), - focusManifest: z.record(z.string(), z.unknown()).optional(), + focusManifest: focusManifestInputSchema.optional(), branchEligibility: callerBranchEligibilitySchema.optional(), localScorer: z .object({ diff --git a/test/unit/routes-remediation-plan.test.ts b/test/unit/routes-remediation-plan.test.ts index 143b67ca44..8013d8d296 100644 --- a/test/unit/routes-remediation-plan.test.ts +++ b/test/unit/routes-remediation-plan.test.ts @@ -4,8 +4,13 @@ import { createSessionForGitHubUser } from "../../src/auth/security"; import { upsertInstallation, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; import { createTestEnv } from "../helpers/d1"; +import { MAX_FOCUS_MANIFEST_BYTES } from "../../src/signals/focus-manifest"; const PATH = "/v1/local/remediation-plan"; +const BRANCH_ANALYSIS_PATH = "/v1/local/branch-analysis"; + +// Mirrors LOCAL_BRANCH_ANALYSIS_MAX_BODY_BYTES in src/api/routes.ts (1 MiB). +const MAX_BODY_BYTES = 1024 * 1024; function apiHeaders(env: Env): Record { return { @@ -116,3 +121,89 @@ describe("remediation-plan route", () => { }); }); }); + +describe("local branch routes byte-cap ingestion bound", () => { + for (const path of [PATH, BRANCH_ANALYSIS_PATH]) { + it(`rejects an oversized request body with 413 payload_too_large via Content-Length (${path})`, async () => { + const app = createApp(); + const env = createTestEnv(); + await seedRepo(env, "miner", "demo", 301); + const response = await app.request( + path, + { + method: "POST", + headers: { ...apiHeaders(env), "content-length": String(MAX_BODY_BYTES + 1) }, + body: JSON.stringify(branchPayload("oktofeesh1", "miner/demo")), + }, + env, + ); + expect(response.status).toBe(413); + await expect(response.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: MAX_BODY_BYTES }); + }); + + it(`rejects an oversized streamed request body with 413 payload_too_large (${path})`, async () => { + const app = createApp(); + const env = createTestEnv(); + await seedRepo(env, "miner", "demo", 301); + // A body that exceeds the cap forces the streaming reader (readRequestBodyWithLimit) to bail + // out even when the Content-Length header is absent/under the limit. + const oversizedBody = JSON.stringify( + branchPayload("oktofeesh1", "miner/demo", { body: "x".repeat(MAX_BODY_BYTES + 16) }), + ); + const response = await app.request( + path, + { + method: "POST", + headers: apiHeaders(env), + body: oversizedBody, + }, + env, + ); + expect(response.status).toBe(413); + await expect(response.json()).resolves.toMatchObject({ error: "payload_too_large", maxBytes: MAX_BODY_BYTES }); + }); + + it(`rejects a focusManifest that serializes beyond the byte cap with 400 (${path})`, async () => { + const app = createApp(); + const env = createTestEnv(); + await seedRepo(env, "miner", "demo", 301); + // Stays under the 1 MiB request-body cap but blows past the focus-manifest serialized byte cap, + // so it must trip the focusManifestInputSchema refinement (not the body limit). + const oversizedManifest = { present: true, note: "a".repeat(MAX_FOCUS_MANIFEST_BYTES + 1) }; + const requestBody = JSON.stringify(branchPayload("oktofeesh1", "miner/demo", { focusManifest: oversizedManifest })); + expect(requestBody.length).toBeLessThanOrEqual(MAX_BODY_BYTES); + const response = await app.request( + path, + { + method: "POST", + headers: apiHeaders(env), + body: requestBody, + }, + env, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_local_branch_analysis_request" }); + }); + + it(`accepts a normal-size body and within-cap focusManifest with 200 (${path})`, async () => { + const app = createApp(); + const env = createTestEnv(); + await seedRepo(env, "miner", "demo", 301); + const response = await app.request( + path, + { + method: "POST", + headers: apiHeaders(env), + body: JSON.stringify( + branchPayload("oktofeesh1", "miner/demo", { + focusManifest: { present: true, wantedPaths: ["src/"], source: "caller" }, + }), + ), + }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ login: "oktofeesh1", repoFullName: "miner/demo" }); + }); + } +});