Skip to content

feat: detect paste instead of blocking it - #4

Merged
krishhimself merged 1 commit into
mainfrom
feat/paste-detection
Aug 22, 2026
Merged

feat: detect paste instead of blocking it#4
krishhimself merged 1 commit into
mainfrom
feat/paste-detection

Conversation

@krishhimself

Copy link
Copy Markdown
Owner

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_paste
along with the size of the injection. Nothing is prevented; no warning is shown.

The heuristic lives in frontend/src/features/quiz/pasteDetect.js rather than inside
the 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:

Scenario Flagged Delta
paste into empty box true 220
typed normally, with the candidate's own backspaces false 0
fast typist (40ms/char, 300 chars) false 0
IME commit (8 chars after a 400ms pause) false 0
paste immediately after a keystroke true 480
paste after a 4s pause false 0

End to end

Five answers submitted with identical seconds_left: 8.0, so the timing heuristic
could not have produced the selection:

q0 flagged=False   q1 flagged=False   q2 flagged=True delta=220  <-- PASTED
q3 flagged=False   q4 flagged=False

follow-up targeted question index: 2 (expected 2) -> CORRECT

The follow-up quoted the pasted text back:

"You stated that 'error handling is surfaced at the boundary' to keep consumers
'insulated from lower-level details' — if the underlying CookieJar throws on a
malformed header, what specific exception does your boundary raise?"

Flags persist to Mongo: [(False,0), (False,0), (True,220), (False,0), (False,0)].

Decisions worth reviewing

paste_delta added to the schema. Ranking multiple flagged answers by "largest
single 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

pytest passes, vite build clean, 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

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
@krishhimself
krishhimself merged commit e6a27c3 into main Aug 22, 2026
2 checks passed
@krishhimself
krishhimself deleted the feat/paste-detection branch August 22, 2026 10:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant