From bc071cf693b6d3870aafd00ebc548f96c3a7ddaa Mon Sep 17 00:00:00 2001 From: Krish Date: Sat, 22 Aug 2026 16:14:03 +0530 Subject: [PATCH] feat: detect paste instead of blocking it Blocking paste only teaches a candidate to retype what they pasted, and it tells them they were noticed. Recording it tells us which answer to interrogate while the paste still appears to have worked. React fires one change event per input, so ordinary typing arrives as a stream of single-character deltas. A single event that adds more than 40 characters within 100ms of the previous event did not come from a keyboard, and that answer is marked flagged_paste with the size of the injection. Nothing is prevented and no warning is shown. The detector lives in its own module rather than inside the component so the heuristic is testable without a browser. When choosing which answer gets the adaptive follow-up, a recorded paste now outranks the timing heuristic entirely - it is direct evidence rather than an inference - with ties going to the largest single injection. The previous rate-weighted heuristic remains as the fallback when nothing is flagged. Two notes on the implementation: paste_delta is sent alongside flagged_paste. Ranking multiple flagged answers by "largest single delta" is not possible unless the delta reaches the server. A first input event is treated as instantaneous rather than being excused for having no predecessor to time against. Pasting into an empty box is the ordinary cheat, and requiring a previous keystroke would have exempted exactly the case this exists to catch. Verified by driving the real detector with simulated event streams: a paste into an empty box flags at delta 220, while 240 characters typed at 90-190ms with the candidate's own backspaces does not, and neither does a 40ms/char typist nor an eight-character IME commit after a pause. End to end, with seconds_left held identical across every answer so timing could not explain the choice, the follow-up correctly targeted the pasted question and quoted its wording back. Known seam, now documented in the README: typing, pausing, then pasting clears the 100ms guard. Closing it means judging by implied typing rate rather than a fixed gap, which also risks flagging dictation software. The thresholds are left as specified and the gap is written down rather than papered over. The README previously stated that paste was disabled. That is no longer true, and a wrong security claim is worse than no claim. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PET9qKZXhgjEbZK7MReYQj --- README.md | 27 +++++++++----- backend/app/schemas/quiz.py | 4 +++ backend/app/services/quiz_service.py | 18 +++++++--- frontend/src/features/quiz/QuizPage.jsx | 10 ++++-- .../features/quiz/components/QuestionCard.jsx | 26 +++++++++----- frontend/src/features/quiz/pasteDetect.js | 36 +++++++++++++++++++ 6 files changed, 97 insertions(+), 24 deletions(-) create mode 100644 frontend/src/features/quiz/pasteDetect.js diff --git a/README.md b/README.md index c4246d5..a3f9530 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,18 @@ included — and the box locks. Time remaining at commit is recorded, because a polished answer submitted with most of the clock unspent was not composed in the box. -**Paste is disabled** in the answer field (`paste` and `drop` are both blocked — -drag-and-drop text would otherwise walk straight past a paste-only guard). +**Paste is detected, not blocked.** Blocking it only teaches a candidate to retype +what they pasted; recording it tells us which answer to interrogate. React fires one +change event per input, so ordinary typing arrives as a stream of single-character +deltas — a single event that adds a whole paragraph did not come from a keyboard. +That answer is silently marked. Nothing is prevented and no warning is shown, so the +paste appears to have worked. **One adaptive follow-up, before grading.** Once answers are in, the answer least -likely to have been typed by its author is selected — weighting typing rate by -length, so a suspiciously fast essay outranks a fast one-liner — and a single +likely to have been typed by its author is selected. A recorded paste wins outright, +since it is evidence rather than inference, with ties going to the largest single +injection; failing that, typing rate weighted by length, so a suspiciously fast essay +outranks a fast one-liner. A single follow-up is generated that quotes that answer's specific wording back and pushes on it. Same clock, same no-paste rule. Grading happens only after this round, so a candidate cannot bank a score and abandon the round they cannot pass. @@ -65,12 +71,15 @@ Measured on `psf/requests`, same repo and same time budget: | Pasted AI answer | committed with 63s of 75s left, could not defend its wording | **0/100** | | Genuine author | typed distinct answers, defended the follow-up | **100/100** | -**What this does not do.** The timer and the paste block are client-side. They +**What this does not do.** The timer and the paste detector are client-side. They raise the cost of casual cheating; they do not stop anyone willing to call the API -directly with a forged `seconds_left`. The follow-up round is the measure that -actually holds, because it demands understanding at response time regardless of how -the request was made. Server-issued timestamps at generation, with elapsed time -computed server-side, are the real fix and are not built yet. +directly with a forged `seconds_left` and `flagged_paste: false`. Detection also has +a seam of its own: pasting after a pause long enough to look like thinking clears the +timing guard, and dictation software can legitimately commit a long phrase in one +event. The follow-up round is the measure that actually holds, because it demands +understanding at response time regardless of how the request was made. Server-issued +timestamps at generation, with elapsed time computed server-side, are the real fix and +are not built yet. ### Known limitation: comprehension is not difficulty diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index a21fa09..b85b7a2 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -39,6 +39,10 @@ class QuizAnswer(BaseModel): # Countdown remaining when the answer was committed. A long answer submitted with # most of the clock still left is the signal that it was not typed from scratch. seconds_left: Optional[float] = None + # Client-observed paste signal: a single input event that added a paragraph. + # Recorded silently — the candidate is never told it was noticed. + flagged_paste: bool = False + paste_delta: int = 0 # largest flagged single-event delta, used to rank flags class QuizSubmitRequest(BaseModel): diff --git a/backend/app/services/quiz_service.py b/backend/app/services/quiz_service.py index ab2548e..cbd67b7 100644 --- a/backend/app/services/quiz_service.py +++ b/backend/app/services/quiz_service.py @@ -42,10 +42,14 @@ def pick_suspect_answer(answers: list[dict], time_limit: int = TIME_LIMIT_SECOND """ Choose the answer least likely to have been typed by its author. - Typing pace is the signal. A long, polished answer committed with most of the - clock still unspent was not composed in the box — nobody writes 900 considered - characters in twelve seconds. Weighting rate by length keeps a fast one-liner - from outranking a suspiciously fast essay. + A recorded paste wins outright: the client saw a whole paragraph arrive in one + input event, which is evidence rather than inference. Ties among flagged answers + go to the largest single injection. + + Otherwise typing pace is the signal. A long, polished answer committed with most + of the clock still unspent was not composed in the box — nobody writes 900 + considered characters in twelve seconds. Weighting rate by length keeps a fast + one-liner from outranking a suspiciously fast essay. Falls back to the first non-empty answer, then to the first answer, so the follow-up round always happens even with no timing data at all. @@ -54,6 +58,12 @@ def pick_suspect_answer(answers: list[dict], time_limit: int = TIME_LIMIT_SECOND if not answered: return answers[0] if answers else None + # A recorded paste outranks every timing heuristic — it is direct evidence rather + # than an inference. Among several, push on the largest single injection. + pasted = [a for a in answered if a.get("flagged_paste")] + if pasted: + return max(pasted, key=lambda a: a.get("paste_delta") or 0) + timed = [a for a in answered if a.get("seconds_left") is not None] if not timed: return answered[0] diff --git a/frontend/src/features/quiz/QuizPage.jsx b/frontend/src/features/quiz/QuizPage.jsx index 0b3fb9e..1518e15 100644 --- a/frontend/src/features/quiz/QuizPage.jsx +++ b/frontend/src/features/quiz/QuizPage.jsx @@ -17,6 +17,9 @@ export default function QuizPage() { // Live countdown per question, kept in a ref so ticking never re-renders the page. const timeLeft = useRef({}); + // Paste signals per question. Also a ref — recording must stay invisible, and + // re-rendering on it would risk leaking that something was noticed. + const inputSignal = useRef({}); // Guards against the auto-submit firing twice (expiry racing a manual click). const sent = useRef({ answers: false, followup: false }); @@ -34,6 +37,7 @@ export default function QuizPage() { setFollowupAnswer(""); setExpired(new Set()); timeLeft.current = {}; + inputSignal.current = {}; sent.current = { answers: false, followup: false }; try { const data = await generateQuiz(repoUrl); @@ -57,6 +61,8 @@ export default function QuizPage() { question_id: q.id, answer: answers[q.id] || "", seconds_left: timeLeft.current[q.id] ?? null, + flagged_paste: inputSignal.current[q.id]?.flagged_paste ?? false, + paste_delta: inputSignal.current[q.id]?.paste_delta ?? 0, })); setFollowup(await submitQuiz(quiz.quiz_id, payload)); } catch (e) { @@ -109,8 +115,7 @@ export default function QuizPage() { {quiz && !followup && !result && (

- {limit}s per question · typing only, paste is disabled · answers lock when the - timer runs out + {limit}s per question · answers lock when the timer runs out

{quiz.questions.map((q) => ( (timeLeft.current[id] = s)} onExpire={markExpired} + onInputSignal={(id, sig) => (inputSignal.current[id] = sig)} onAnswerChange={(id, val) => setAnswers((prev) => ({ ...prev, [id]: val }))} /> ))} diff --git a/frontend/src/features/quiz/components/QuestionCard.jsx b/frontend/src/features/quiz/components/QuestionCard.jsx index de932ad..43ba2bb 100644 --- a/frontend/src/features/quiz/components/QuestionCard.jsx +++ b/frontend/src/features/quiz/components/QuestionCard.jsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { createInputTracker } from "../pasteDetect"; /** * One timed question. @@ -8,9 +9,10 @@ import { useEffect, useRef, useState } from "react"; * having its timers throttled. On expiry the answer is committed as-is — blank * included — and the box locks. * - * Paste is blocked so the answer has to be typed. This is a speed bump, not a - * security control: it stops the casual paste from another tab, and the real - * signal is the typing pace reported via onTick. + * Paste is deliberately NOT blocked. It is recorded instead: a single change event + * that adds a paragraph is reported upward as flagged_paste. The candidate sees no + * warning and nothing is prevented, so a paste still looks to them like it worked. + * The flag decides which answer gets pushed on in the follow-up round. */ export default function QuestionCard({ question, @@ -18,9 +20,12 @@ export default function QuestionCard({ onAnswerChange, onExpire, onTick, + onInputSignal, timeLimit = 75, }) { const [left, setLeft] = useState(timeLimit); + const tracker = useRef(null); + if (tracker.current === null) tracker.current = createInputTracker(); const cbs = useRef({ onExpire, onTick }); cbs.current = { onExpire, onTick }; @@ -45,12 +50,17 @@ export default function QuestionCard({ return () => clearInterval(handle); }, [question.id, timeLimit]); + function handleChange(e) { + const next = e.target.value; + tracker.current.record(answer || "", next, Date.now()); + onInputSignal?.(question.id, tracker.current.snapshot()); + onAnswerChange(question.id, next); + } + const locked = left === 0; const mins = Math.floor(left / 60); const secs = String(left % 60).padStart(2, "0"); - const block = (e) => e.preventDefault(); - return (

@@ -67,11 +77,9 @@ export default function QuestionCard({