Skip to content

OA Simulator & Exam Integrity: Proctoring & Detection Logic

Part 4 of 5 in the OA Simulator & Exam Integrity series.

Risk Engine & the Hash-Chained Audit Log covered how events become a score and how the score's history is tamper-evident. This page covers where the events actually come from: the sensors, and why desktop proctoring is a fundamentally deeper capability than anything a browser tab can offer.

DOM sensors: signals, never content

Inside the webview, useIntegrityMonitor.js wires visibilitychange/blur/focus, a capture-phase paste listener, and a fixed keyboard shortcut list. The clipboard sensor is the most deliberate piece: not a copy/paste ban but a four-way origin classifier. Every in-exam copy fingerprints the selection (djb2 hash + length, in memory only, capped at 300, content never stored). On paste: a known fingerprint is internal (weight 0, silent — moving your own code around is always free); unknown text under 40 characters is short (too ambiguous to score — a variable name); no text at all is unknown; unknown text at or above 40 characters is external, and that is the only case a policy's restrict-external mode blocks. Keyboard monitoring follows the same discipline — PrintScreen, F12, Ctrl+Shift+I/J/C are logged by name, throttled to one event per key per 5 seconds; OS-reserved combos like Alt-Tab never reach a webview at all, so their effect (a blur event) is logged instead of the keystroke itself.

The Rust system watcher

integrity.rs runs one adaptive background task per live session, entirely on Tauri's blocking pool:

rust
// Simplified real shape (integrity.rs)
const BASE_INTERVAL_MS: u64 = 4000;   // while things are changing
const IDLE_INTERVAL_MS: u64 = 10000;  // after 5 consecutive quiet scans
async fn scan_flagged(rules: Arc<Vec<ProcessRule>>) -> HashSet<(String, String)> {
    spawn_blocking(move || flagged_processes(&mut System::new(), &rules)).await.unwrap_or_default()
}
// loop: diff this scan against the last one; emit only what changed
// (a quiet system produces zero events, zero writes, zero renders)

Cadence is diff-only and adaptive: fast while flags are appearing, backing off to a slow idle poll on a calm machine, and woken immediately by integrity_poke the instant the frontend detects focus loss — the highest-signal moment for a flagged app to appear. Process classification is data-driven, not hardcoded: a JSON rule set (built-in defaults, overridable via a meta key without a rebuild) matches on name substrings, exact names, and install-path substrings — the path clause is the first real counter to a renamed chrome.exe, since a relocated renamed binary still lives under \Google\Chrome\.

One specific false positive is worth calling out because the fix is elegant: the CP section renders Codeforces problems in a native child webview, and clicking into that panel fires the main window's blur/focus-lost event even though focus never left the app's own process. Rather than blanket-muting focus events during CP (which would blind the watcher to real app switches), app_owns_foreground() asks Windows directly — a tiny user32 FFI call (GetForegroundWindow + GetWindowThreadProcessId) compares the OS foreground window's owning process ID against the app's own PID. If they match, focus went to the embedded panel and is ignored; if not, the candidate genuinely switched applications.

Vision: MediaPipe, not a proctoring-trained model, and honest about it

useVisionMonitor.js samples the webcam at roughly 2–3.5 fps (adaptive, backing off when calm), draws each frame once to a 160×120 offscreen canvas, and runs three offline-bundled MediaPipe models — FaceLandmarker, HandLandmarker, an ObjectDetector (EfficientDet-Lite2) — plus model-free pixel statistics for camera health (dark/bright/blurred/frozen/covered). VISION_INTEGRITY.md is explicit that these are pretrained COCO/MediaPipe models, not a proctoring-fine-tuned YOLO — the accuracy ceiling this sets (documented, not hidden) is what a native inference sidecar would be needed to raise further.

Every conclusion runs through a hysteresis ConditionTracker, not a per-frame threshold:

javascript
// Confidence model (visionAggregator.js) — never hardcoded per fire
confidence = base(catalog) * quality * duration * stability;  // clamped [0.1, 0.97]
// quality:   0.55 + 0.45·q        detector's trust in the frame
// duration:  1 + 0.3·min(1, dur/escalateMs)   persistence raises confidence
// stability: 0.6 + 0.4·s          fraction of recent samples agreeing

A condition must hold for enterMs (0.5–3s depending on type) before it commits — a blink or a lighting flicker produces nothing — and must be absent for exitMs before clearing, so a signal hovering at threshold doesn't chatter. A lone vision event stays deliberately cheap; it earns real weight only through fusion with an independent system signal: vision_face_lost/extended_absence combined with tab_switch/blur within 20s adds +18, vision_multiple_faces combined with a flagged app within 2 minutes adds +22 — the strongest "someone is helping" pattern the system has.

Risk score vs. mark penalty: two different questions

penaltyEngine.js deliberately does not read the risk score. It answers a narrower, fully auditable question: given the marking scheme, how many marks does sustained misconduct cost? Each violation category (app switching, external paste, face absent, multiple faces, looking away, flagged app, camera obstruction, device in view) gets independent counters: the first freeWarnings occurrences cost nothing, and each occurrence past that deducts basePct × (1 + (k−1)·escalation), capped per category and then again at a maxTotalPct grand cap (60% by default) — a deterministic number a candidate can reconstruct by hand from their own timeline, applied by applyPenaltyToScore after the marking-scheme score from Marking Schemes & Scoring is already computed.

Desktop vs. web: an honest capability gap

Desktop (Tauri)Web
Tab/window switchvisibilitychange + risk scoreuseAntiCheat: detected, warned
Fullscreen enforcementPolicy-configurableEnforced during attempt
Process/app detectionsysinfo watcher, data-driven rulesNot possible (browser sandbox)
Clipboard originFingerprint classifier, can block externalNot accessible
Camera/visionFull MediaPipe pipeline, opt-inNot implemented
Tamper-evident logHash-chained SQLiteNone
Stated scopePractice tool raising attacker cost"Discourages casual cheating," explicitly not a security boundary

OA Simulator states the web distinction plainly: useAntiCheat is a UX/integrity feature, not a security boundary. That is an honest, deliberate line — not an oversight — because "how do you stop cheating" has a genuinely different real answer for a self-practice tool than for a company's actual hiring pipeline, and Ediky's desktop build only reaches for the heavier machinery where the format calls for it.

Self-assessment

Word count for this page: 947 words (over target to cover both sensor families, the foreground-ownership fix, and the penalty/risk distinction without cutting any short).


← Previous: Risk Engine & the Hash-Chained Audit Log→ Next: Results, Analytics & the CP Simulator Compared

Ediky Workflow — internal engineering documentation.