A HackerRank companion proctor (evidence collection + triage, not lockdown) plus a contest-eval live monitoring tool. Students open the proctor app before a contest, register, share their entire screen, and keep recording while evidence streams to Google Cloud Storage. In parallel, a Python poller watches the live HackerRank contest, runs deterministic cheating analysis each cycle, and feeds integrity alerts into the same admin console the proctor evidence flows into.
This is honest, browser-based proctoring. A plain web app can record a user-selected shared screen, detect when this proctor page is hidden or unfocused, detect when screen sharing stops, capture copy/paste inside the app, and upload video chunks + JSONL event logs. It cannot force-close tabs, enumerate other tabs, continuously read the OS clipboard, see a second device, or catch an overlay on another monitor — no browser app can without a managed extension or endpoint agent. So the spine of integrity here is not the browser: it is the live submission-eval (peer-copy clusters, recurring pairs, web/editorial paste, first-attempt-on-a-tough-question) plus human review of the recorded evidence. Treat the proctor side as evidence collection and triage for review, never as automatic disqualification.
Deeper background and the decisions behind this design live in
docs/PROCTORING_RESEARCH.md,
docs/PLATFORM_ALTERNATIVES.md, and
docs/ROADMAP.md. Read those rather than expecting this README
to re-derive them.
Four components share one alerts pipeline and one storage convention:
┌─────────────────────────────┐
student browser → │ frontend/ (React + Vite) │ ← /admin console (same app)
│ recorder + admin console │
└──────────────┬──────────────┘
signed-URL PUT (video chunks)│ JSON (start/heartbeat/events/end,
+ JSONL events to GCS │ admin stats/alerts/actions)
▼
┌─────────────────────────────┐ ┌───────────────┐
│ backend/ (Cloud Run HTTP) │◀──────▶│ Firestore │
│ handler.mjs │ sessions, settings,
│ - session lifecycle │ alerts, live-locks
│ - signed evidence uploads │ └───────────────┘
│ - alerts ingest + admin read│ ┌───────────────┐
│ - sure-shot proctor alerts │◀──────▶│ GCS (evidence│
└───┬────────────────────▲─────┘ chunks│ + manifests) │
POST /api/alerts │ (x-api-key) │ video_key └──────┬────────┘
(shared contract)│ │ deep-link │ chunks
│ │ ▼
┌─────────────────────┴───────┐ ┌─────────┴──────────┐ ┌──────────────┐
│ monitoring/ (Python poller)│ │ tab_away_detector │ │ video-worker/│
│ - cdp.py drives Chrome:9222│ │ (S1, local CV on │ │ merge chunks│
│ - deterministic analysis │ │ a recording) │ │ → review vid│
│ - 429-safe lazy code fetch │ └────────────────────┘ │ writes back │
│ - LLM verdict seam (files) │ │ merged_video_│
└─────────────────────────────┘ │ key on doc │
└──────────────┘
- frontend/ — React/Vite/TS/Tailwind.
/is the student recorder;/adminis the console. Demoable withVITE_DEMO_MODE(no backend). →frontend/README.md - backend/ — one Cloud Run HTTP handler (
src/handler.mjs). Owns sessions, signed uploads, alerts ingest/read, settings, admin actions. State in Firestore + GCS. →backend/README.md - video-worker/ — optional Cloud Run service that merges screen chunks into one
review video and writes its key back onto the session. →
video-worker/README.md - monitoring/ — standalone Python contest-eval poller + the file-queue LLM
verdict seam + the tab-away detector. POSTs alerts to
/api/alerts. →monitoring/README.md
How they connect: every producer (the proctor recorder via the backend, the
contest-eval poller, the tab-away detector) emits the same shared Alert JSON
contract and they all land in one Firestore collection that the admin console
reads. Ambiguous contest-eval alerts route through a file-queue verdict seam
(night-run/verdict-queue/pending → done) that a Claude Code /loop resolves.
Evidence is stored under one contest-foldered GCS prefix every component agrees on.
- Passcode-free session model — start is gated by the contest time window
only; end by the integrity-assurance checkbox only. A browser reload
resumes the same session without re-collecting details. Single active
session per
(username_norm, contest_slug)enforced by an atomic Firestore live-slot lock; a second device lands inpending_approval. Admin actions: approve (activate pending + end the conflicting one), lock/unlock, bypass (activate without ending the other), end. - Contest-foldered GCS storage — every per-session object is keyed off one
persisted
storage_prefix:contests/<slug>/sessions/<username_norm>/<session_id>/…(legacysessions/<username_norm>/<session_id>/…when no contest URL). Built once at start; upload/signing/evidence-listing/merge all reuse it with zero extra reads. - Sure-shot proctor alerts — selected high-signal proctor events become
idempotent
source:"proctor"alerts (recording stopped, screen-share stopped, recording error, IP changed, proctor tab hidden) with avideo_keydeep-link. Noisy focus/blur/visibility/clipboard events are intentionally not surfaced. - Alerts ingestion API + admin Live Alerts Console —
POST /api/alerts(x-api-key, closed-by-default) ingests one or a batch; the console lists them newest-first with archive, room/severity/source filters, and video deep-links (short-lived signed read URLs resolved at read time, never stored). - Live stats dashboard + near-live signal —
/api/admin/statscounts by status (live/locked/pending/finished + a derived disconnected count) with a room dropdown; the console auto-polls every 5s. Near-live disconnection comes from a tab-close beacon (navigator.sendBeacon) + heartbeat-staleness detection. - Contest-eval poller — unattended live polling:
cdp.pydrives an already-logged-in Chrome on:9222, opening and closing its own background tab. Deterministic metadata analysis each cycle; lazy, 429-safe code fetch for flagged candidates only (hardest-first, never stores a failed fetch). - LLM verdict seam — ambiguous alerts route to a file-queue a Claude Code
/loopresponder drains (subscription only, no paid API). Interface is swappable (a future C3 transport can replace the filesystem without touching the poller). - Tab-away (S1) detector — local image-recognition over a screen recording:
flags continuous spans where the HackerRank header logo is absent as
tab_awayalerts deep-linked to the recording. →monitoring/tab-away-README.md - Student guided UX + recovery — step banner, identity confirmation, periodic integrity checkpoints, blocked-screen self-service re-check, and inline recovery for invalid share surface (the recorder refuses anything but Entire Screen), cancelled share, and a failed end-submit — never a forced reload.
- Admin password hashing — the frontend ships only a sha256 hash
(
VITE_ADMIN_PASSWORD_HASH) and the unlock gate hashes the typed password to compare; the plain password is no longer baked into the bundle.
All routes live in backend/src/handler.mjs. Auth columns: x-api-key =
ALERTS_INGEST_API_KEY (timing-safe); x-admin-password = ADMIN_PASSWORD;
session = knowing the session_id (no header). CORS allows
GET,POST,OPTIONS.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /api/session/start |
none (time-window gate) | Register/start a session, or idempotently replay an owned session_id. Second device for an active (user, contest) → pending_approval. |
| POST | /api/session/resume |
session | Return an existing session verbatim after a reload (no re-collection). 404 on unknown/mismatched token. |
| POST | /api/upload-url |
session (writable) | Mint a v4 signed write URL for a video chunk under the session prefix. |
| POST | /api/events |
session (writable) | Append a JSONL batch of client events; raise sure-shot alerts for high-signal types. |
| POST | /api/review-file |
session (writable) | Store a review record set (clipboard / tabs / cookies). |
| POST | /api/heartbeat |
session (writable) | Liveness + recording state + IP; raises recording_stopped / ip_changed sure-shots; returns live status. |
| POST | /api/session/beacon |
session (no admin auth; sendBeacon-friendly) | Liveness beacon (hidden/visible/closing); hidden/closing raise the tab_hidden sure-shot. Not writable-gated (locked/ended can still report liveness). |
| POST | /api/session/validate-end |
session (writable) | Pre-flight the end: requires assurance_accepted:true. |
| POST | /api/session/end |
session (writable) | End the session, write manifest.json, release the live slot. Requires assurance_accepted:true. |
| GET | /api/admin/settings |
x-admin-password | Read the schedule + contest URL (public/sanitized view). |
| POST | /api/admin/settings |
x-admin-password | Save start_at/end_at (+ optional contest_url); derives contest_slug. |
| GET | /api/admin/sessions?username= |
x-admin-password | One user's recent sessions + their evidence objects with signed read URLs. |
| GET | /api/admin/stats?contest_slug=&room= |
x-admin-password | Counts by status (live/locked/pending/finished/disconnected/total) + the rooms list. |
| POST | /api/admin/session-action |
x-admin-password | approve|lock|unlock|bypass|end one session_id or each of usernames[]. |
| POST | /api/alerts |
x-api-key | Ingest one alert (bare object) or a batch ({alerts:[…]}, max 500); idempotent merge on alert.id. Rejects all if the key is unset. |
| GET | /api/admin/alerts |
x-admin-password | List alerts newest-first (≤500); filters contest_slug/severity/source/room + include_archived; fills download_url from video_key; returns rooms. |
| POST | /api/admin/alert-action |
x-admin-password | archive|unarchive a set of alert ids. |
| GET | /api/admin/alert-settings |
x-admin-password | Full per-type proctor alert config (defaults merged with overrides). |
| POST | /api/admin/alert-settings |
x-admin-password | Upsert per-type proctor alert config (unknown types dropped, bad severities defaulted). |
Any other path → 404. Intentional 4xx errors echo a detail message; unexpected
errors return a generic 500 with no internal detail.
Every producer and the backend agree on this shape (required on ingest: source,
type, severity, timestamp, hackerrank_username, title):
Two producers, two config surfaces. Verified against the code as of this writing.
source:"proctor". The configurable catalog and defaults (DEFAULT_PROCTOR_ALERT_SETTINGS
in handler.mjs) — every type enabled by default; a disabled type is skipped and a
configured severity overrides the default:
| Type | Default severity | Raised by |
|---|---|---|
recording_stopped |
critical | /api/events event or /api/heartbeat with a stopped composite recording_state |
screen_share_stopped |
critical | /api/events event |
recording_error |
critical | /api/events event |
ip_changed |
warning | server-derived on /api/heartbeat |
tab_hidden |
warning | /api/session/beacon kind:"hidden"/"closing" |
tab_away |
warning (+ threshold_seconds, default 12) |
the monitoring tab-away detector; threshold_seconds is the source of truth for its --min-gap-seconds |
disconnected |
warning | reserved type; also surfaced as a derived count in /api/admin/stats |
invalid_share_surfacewas removed from the catalog — the recorder now refuses to record on any non-monitorshare surface (throws before recording), so the event can never fire. Existing stored alerts of that type still display, but it is no longer raised or configurable.
source:"contest-eval", built in monitoring/alerts.py. enabled gates whether a
type is produced; severity (non-null) overrides the dynamic severity (which also
drives verdict-seam routing):
| Type | Default severity | Meaning |
|---|---|---|
peer_copy_cluster |
critical (config); dynamic critical on HARD / warning on MED | >1 distinct user with identical (skeleton) code on one MED/HARD problem (EASY/SQL dropped) |
recurring_pair |
critical (config); dynamic critical if 2+ shared / warning if single-hard | a pair sharing identical code; the most conclusive signal |
web_paste |
warning | strong web/editorial provenance signature in fetched accepted code (Java class Solution template FP suppressed) |
first_attempt_solve |
info | problem ACCEPTED on the candidate's first attempt, normal problem — a corroborator, never a standalone flag |
tough_first_attempt |
critical | a first-attempt solve on a tough problem (operator-marked in tough_questions OR data-derived hard, ≤10 solvers) — the real flag |
fast_solveis a deprecated alias offirst_attempt_solve: it still loads fromalert-config.json(seeding thefirst_attempt_solvedefaults when that key is absent), but no alerts are emitted under that name anymore.
| Variable | Default | Purpose |
|---|---|---|
EVIDENCE_BUCKET |
(required) | GCS bucket for evidence chunks, event JSONL, manifests, and the signing target for alert video_key. |
ADMIN_PASSWORD |
(required) | Secret for all /api/admin/* (x-admin-password). |
ALERTS_INGEST_API_KEY |
none → reject all | Shared secret for POST /api/alerts (x-api-key, timing-safe). Unset = closed. Generate with openssl rand -base64 32. |
ALERTS_COLLECTION |
proctor_alerts |
Firestore collection for alerts. |
SESSION_COLLECTION |
proctor_sessions |
Firestore collection for session docs. |
SETTINGS_COLLECTION |
proctor_settings |
Firestore collection for schedule + alert settings docs. |
LIVE_LOCK_COLLECTION |
proctor_live_locks |
Firestore collection for the single-active-session live-slot locks. |
PUBLIC_APP_ORIGIN |
* |
CORS access-control-allow-origin. Lock to the frontend URL in production. |
URL_EXPIRY_SECONDS |
900 |
Lifetime of signed upload/read URLs (seconds). |
DISCONNECTED_STALENESS_MS |
45000 |
An active session whose newest liveness signal is older than this counts as disconnected in stats. |
| Variable | Purpose |
|---|---|
VITE_API_BASE_URL |
Backend base URL the app calls. |
VITE_DEMO_MODE |
true runs the entire UI on a localStorage fake (no backend). |
VITE_ADMIN_PASSWORD |
Plain admin password (used only by demo-mode local builds). |
VITE_ADMIN_PASSWORD_HASH |
sha256 of ADMIN_PASSWORD shipped in production builds; the unlock gate hashes the typed password to compare. The plain password is not put in the bundle by deploy-gcp.sh. |
| Variable | Default | Purpose |
|---|---|---|
SOURCE_BUCKET |
${PROJECT_ID}-proctor-evidence |
Bucket holding screen chunks (usually the evidence bucket). |
DEST_BUCKET |
${PROJECT_ID}-proctor-review-videos |
Bucket for merged review videos + manifests. |
WORKER_TOKEN |
(required) | Bearer/x-worker-token secret for POST /merge. |
SESSION_COLLECTION |
proctor_sessions |
Must match the backend so merged_video_key write-back hits the right doc. |
MAX_USERNAMES_PER_REQUEST |
25 |
Cap on usernames merged in one request. |
Carries PROJECT_ID, REGION, REPOSITORY, the secrets above, the three
bucket names, the three Cloud Run *_SERVICE_NAMEs, and API_URL. See the file
for the gcloud commands that discover each value.
Local UI-only demo (no backend, no GCP):
npm install
VITE_DEMO_MODE=true VITE_ADMIN_PASSWORD=dev npm run dev
# student: http://localhost:5173/ · admin: http://localhost:5173/admin (unlock: dev)Against a deployed/local backend: set frontend/.env.local with
VITE_API_BASE_URL=<backend url> (and VITE_ADMIN_PASSWORD = the backend
ADMIN_PASSWORD), then npm run dev.
Monitoring tool (poller + verdict responder + tab-away): the full runbook —
backend locally, admin console, fixtures + live poller, and the /loop verdict
responder — is in night-run/HOW-TO-RUN.md. Fastest
check: bash monitoring/run-demo.sh (offline end-to-end, self-cleaning).
GCP deploy: copy .env.deploy.example → .env.deploy.local, fill it, then run
the deploy scripts from the repo root in order:
backend/deploy-gcp.sh → frontend/deploy-gcp.sh → (optional) video-worker/deploy-gcp.sh.
The scripts enable APIs and create missing buckets/repos/indexes idempotently. Full
step-by-step (including locking CORS to the frontend origin) is below in
Deploy details.
| Path | What lives here |
|---|---|
backend/ |
The one HTTP handler (src/handler.mjs) — sessions, uploads, alerts, settings, admin actions — its deploy script, Firestore index, and mocked-GCP tests. |
frontend/ |
The React app (src/App.tsx student + admin; src/useProctorRecorder.ts recorder; src/api.ts incl. demo shim; src/types.ts shared contract). |
video-worker/ |
Optional Cloud Run merge service (src/server.mjs). |
monitoring/ |
Python poller (poller.py), analysis core (contest_eval_core.py), alert builder (alerts.py) + alert-config.json, CDP driver (cdp.py), verdict seam (verdict_seam.py) + responder prompt, tab-away detector (tab_away_detector.py), tests, and several deep READMEs. |
night-run/ |
The overnight build's runbook (HOW-TO-RUN.md), open-items review (MORNING-REVIEW.md), log, goal, PR body, and the verdict-queue/. |
docs/ |
Background research: ROADMAP.md, PROCTORING_RESEARCH.md, PLATFORM_ALTERNATIVES.md. |
scripts/ |
merge-gcs-videos.mjs — local one-shot video-merge helper. |
spike/ |
Throwaway iframe + MV3-extension spikes (not part of the running system). |
.env.deploy.example |
The full deployment env template. |
Key files to start from: backend/src/handler.mjs (every route + env var),
frontend/src/App.tsx + frontend/src/api.ts, monitoring/poller.py +
monitoring/alerts.py, video-worker/src/server.mjs.
Test / verify commands:
| Command | Covers |
|---|---|
npm run backend:test |
Backend handler (mocked Firestore/Storage) — 111 tests. |
python3 monitoring/test_monitoring.py |
Contest-eval core, verdict seam, alert build/idempotency — 60 tests. |
python3 monitoring/test_tab_away.py |
Tab-away pipeline + contract (synthesizes its own clip). |
python3 monitoring/validate_fixtures.py |
Byte-for-byte reproduction of clone_analysis.json. |
npm run lint |
Frontend type-check (tsc -b). |
npm run build |
Frontend production build. |
bash monitoring/run-demo.sh |
Offline poller → ingest → admin-read end-to-end. |
This repo was built out heavily overnight. The current open items — what is done
(e.g. C1, the admin-password hashing, is now done — the frontend ships only the
hash), what is untested against real GCP (the contest-folder upload path, a real
POST→Firestore→GET round-trip, the cross-bucket video_key deep-link, the
video-worker merge + Firestore write-back, the composite index), and the deferred
hardening (the admin-auth architecture call, the session_id-as-sole-bearer
hardening, and other escalated findings) — are tracked in
night-run/MORNING-REVIEW.md. Read it before a
real contest.
The deploy scripts assume gcloud is authenticated and .env.deploy.local is
sourced. Each script is idempotent (re-running is safe).
brew install --cask google-cloud-sdk # or your platform's gcloud install
gcloud auth login
cp .env.deploy.example .env.deploy.local # then fill it in (keep it private)
gcloud config set project YOUR_PROJECT_ID
set -a; source .env.deploy.local; set +a1. Backend (enables APIs; creates Firestore, the evidence bucket, the Artifact
Registry repo, and the username_norm+contest_slug composite index; grants IAM;
builds + deploys):
SERVICE_NAME="$BACKEND_SERVICE_NAME" ./backend/deploy-gcp.sh
export API_URL="$(gcloud run services describe "$BACKEND_SERVICE_NAME" --region "$REGION" --format='value(status.url)')"2. Frontend (builds with VITE_API_BASE_URL + VITE_ADMIN_PASSWORD_HASH,
deploys to Cloud Run). Admin page is the same URL with /admin:
SERVICE_NAME="$FRONTEND_SERVICE_NAME" ./frontend/deploy-gcp.sh3. (Optional) Video worker (creates the review-video bucket, deploys the
protected /merge endpoint):
SERVICE_NAME="$VIDEO_WORKER_SERVICE_NAME" ./video-worker/deploy-gcp.sh4. (Optional) Lock backend CORS to the frontend origin and redeploy:
export PUBLIC_APP_ORIGIN="$(gcloud run services describe "$FRONTEND_SERVICE_NAME" --region "$REGION" --format='value(status.url)')"
SERVICE_NAME="$BACKEND_SERVICE_NAME" ./backend/deploy-gcp.shPer-session GCS objects key off one persisted storage_prefix:
contests/<contest_slug>/sessions/<username_norm>/<session_id>/... # contest URL set
sessions/<username_norm>/<session_id>/... # legacy fallback
contest_slug is the last path segment of the configured contest URL (run
through the same sanitizeSegment as usernames). The video-worker scans both
layouts.
Tuned for cost: zero min instances, low-bitrate 30s screen chunks, 3-day evidence auto-delete. Video is inherently large — at ~800 students × 90 min expect meaningful GCS usage. Test with 20–30 devices before a real drive.
{ "id": "<source>:<type>:<username_norm>:<contest_slug>:<dedupe>", // stable + idempotent "source": "proctor | contest-eval", "type": "<see alert taxonomy below>", "severity": "critical | warning | info", "timestamp": "<ISO 8601>", "contest_slug": "<optional>", "hackerrank_username": "<required>", "username_norm": "<lowercase/sanitized>", "session_id": "<optional>", "room": "<optional>", "title": "<headline>", "detail": "<optional explanation>", "data": { /* optional structured payload */ }, "video_key": "<optional GCS key; resolved to download_url on READ, never stored>", "verdict": { "status": "pending | real | false_positive | inconclusive" } }