feat: detect paste instead of blocking it - #4
Merged
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PET9qKZXhgjEbZK7MReYQj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the paste block with silent paste detection, and makes that flag the
primary input to adaptive follow-up selection.
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.
How detection works
React fires one change event per input, so ordinary typing arrives as a stream of
single-character deltas. A single event adding >40 characters within 100ms of the
previous event did not come from a keyboard. That answer is marked
flagged_pastealong with the size of the injection. Nothing is prevented; no warning is shown.
The heuristic lives in
frontend/src/features/quiz/pasteDetect.jsrather than insidethe component, so it is testable without a browser.
Selection priority
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
rate-weighted heuristic remains the fallback when nothing is flagged.
Verified across four cases: flagged beats fast-and-long, largest delta wins among
multiple flags, unflagged falls back to timing, and a flagged-but-blank answer is
correctly ignored.
Detector results
Driven with simulated event streams through the real module:
End to end
Five answers submitted with identical
seconds_left: 8.0, so the timing heuristiccould not have produced the selection:
The follow-up quoted the pasted text back:
Flags persist to Mongo:
[(False,0), (False,0), (True,220), (False,0), (False,0)].Decisions worth reviewing
paste_deltaadded to the schema. Ranking multiple flagged answers by "largestsingle delta" is impossible unless the delta reaches the server.
A first input event counts as instantaneous. With no prior keystroke there is no
gap to measure, and pasting into an empty box is the ordinary cheat — requiring a
predecessor would exempt exactly the case this exists to catch.
A seam remains open. Typing, pausing, then pasting clears the 100ms guard (row 6
above). Closing it means judging by implied typing rate rather than a fixed gap, which
also risks flagging dictation software. The specified thresholds are kept and the gap
is documented rather than papered over.
README corrected. It previously stated paste was disabled. That is no longer true,
and a wrong security claim is worse than no claim.
Verification
pytestpasses,vite buildclean, test data cleared.Not verified: the interactive browser flow. The Chrome extension would not connect,
so the detector was exercised through simulated event streams rather than a real paste
into a real textarea. The logic is covered; the DOM wiring is not.
🤖 Generated with Claude Code
https://claude.ai/code/session_01PET9qKZXhgjEbZK7MReYQj