From 4a04cb0eb3ff78302fde93576f62a1ee9d66c58b Mon Sep 17 00:00:00 2001 From: "Sharad." Date: Sat, 12 Sep 2026 14:35:46 +0000 Subject: [PATCH] Validate attempt input in arena pipeline Reject whitespace-only and oversized input in runAttempt before filter, agent, or persistence. Share validateAttemptInput with the API route. Fixes #9 --- app/api/attempt/route.ts | 15 +++++++------- lib/arena.ts | 13 ++++++++++++ tests/pipeline.test.ts | 45 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/api/attempt/route.ts b/app/api/attempt/route.ts index 770b2d9..2e01a11 100644 --- a/app/api/attempt/route.ts +++ b/app/api/attempt/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { runAttempt } from "@/lib/arena"; +import { runAttempt, validateAttemptInput } from "@/lib/arena"; import { getChallenge } from "@/lib/challenges/levels"; import { ensureSession } from "@/lib/http"; import { rateLimit } from "@/lib/ratelimit"; @@ -7,8 +7,6 @@ import { rateLimit } from "@/lib/ratelimit"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -const MAX_INPUT = 4000; - export async function POST(req: Request) { const session = ensureSession(); @@ -31,11 +29,12 @@ export async function POST(req: Request) { { status: 400 }, ); } - if (input.length === 0 || input.length > MAX_INPUT) { - return NextResponse.json( - { error: `input must be 1..${MAX_INPUT} characters` }, - { status: 400 }, - ); + try { + validateAttemptInput(input); + } catch (err) { + const message = + err instanceof Error ? err.message : "invalid input"; + return NextResponse.json({ error: message }, { status: 400 }); } if (!getChallenge(challengeId)) { return NextResponse.json({ error: "unknown challenge" }, { status: 404 }); diff --git a/lib/arena.ts b/lib/arena.ts index fcda7ce..039aff3 100644 --- a/lib/arena.ts +++ b/lib/arena.ts @@ -19,6 +19,17 @@ import type { Verdict } from "./types"; // Scoring rule: only the *first* crack of a level by a session earns points; // subsequent cracks and all failures earn zero. +export const MAX_ATTEMPT_INPUT = 4000; + +export function validateAttemptInput(input: string): void { + if (input.trim().length === 0) { + throw new Error("input must not be empty or whitespace-only"); + } + if (input.length > MAX_ATTEMPT_INPUT) { + throw new Error(`input must be at most ${MAX_ATTEMPT_INPUT} characters`); + } +} + export interface RunAttemptArgs { session: Session; challengeId: string; @@ -44,6 +55,8 @@ export async function runAttempt(args: RunAttemptArgs): Promise throw new Error(`Unknown challenge: ${args.challengeId}`); } + validateAttemptInput(args.input); + const priorAttempts = args.dryRun ? args.priorAttempts ?? 0 : await countAttempts(args.session.sessionId, challenge.id); diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts index cdbf271..3f7b739 100644 --- a/tests/pipeline.test.ts +++ b/tests/pipeline.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { runAttempt } from "@/lib/arena"; +import * as agentModule from "@/lib/agent"; +import { MAX_ATTEMPT_INPUT, runAttempt } from "@/lib/arena"; import { countAttempts, getLeaderboard, @@ -20,10 +21,52 @@ describe("end-to-end attempt pipeline with persistence", () => { }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); }); + it("rejects whitespace-only input before agent or persistence", async () => { + const respond = vi.fn(); + vi.spyOn(agentModule, "getAgent").mockReturnValue({ + name: "test", + isAvailable: () => true, + respond, + }); + + await expect( + runAttempt({ + session: alice, + challengeId: "level-1-open-book", + input: " ", + }), + ).rejects.toThrow(/input/i); + + expect(respond).not.toHaveBeenCalled(); + expect(await countAttempts("alice-1", "level-1-open-book")).toBe(0); + }); + + it("rejects oversized input before agent or persistence", async () => { + const respond = vi.fn(); + vi.spyOn(agentModule, "getAgent").mockReturnValue({ + name: "test", + isAvailable: () => true, + respond, + }); + + const oversized = "x".repeat(MAX_ATTEMPT_INPUT + 1); + await expect( + runAttempt({ + session: alice, + challengeId: "level-1-open-book", + input: oversized, + }), + ).rejects.toThrow(/input/i); + + expect(respond).not.toHaveBeenCalled(); + expect(await countAttempts("alice-1", "level-1-open-book")).toBe(0); + }); + it("persists a failed attempt with zero points", async () => { const out = await runAttempt({ session: alice,