cyborg-hunter 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,404 @@
1
+ // src/core/monitor.js
2
+ // Main CyborgHunter orchestrator.
3
+ // Imports signal modules, manages lifecycle, exposes public API.
4
+ //
5
+ // The public API is a frozen object returned by init(). Internal state
6
+ // is closure-scoped so participants cannot disable monitoring via console.
7
+
8
+ import { createStateMachine, deepCopy } from './state-machine.js';
9
+ import { DEFAULT_THRESHOLDS, PRESETS, VERSION } from '../shared/constants.js';
10
+ import { validateConfig } from '../shared/validation.js';
11
+ import { computeTrialScores, shouldScreenout as _shouldScreenout } from './scoring.js';
12
+ import { attachClipboardSignals } from './signals/clipboard.js';
13
+ import { attachFocusSignals, attachIdleGapSignals } from './signals/focus.js';
14
+ import { attachMouseSignals, computeMouseMetrics } from './signals/mouse.js';
15
+ import { attachTypingSignals, attachForeignInputSignals, computeTypingSpeed } from './signals/typing.js';
16
+ import { attachBrowserSignals, attachElementTrace } from './signals/browser.js';
17
+ import { injectDecoy, removeDecoyElement } from './signals/dom-protection.js';
18
+
19
+ let _activeInstance = null; // Track for idempotent init
20
+
21
+ /**
22
+ * Initialize a new CyborgHunter monitor instance.
23
+ * Returns a frozen monitor object with lifecycle methods.
24
+ *
25
+ * @param {Object} userConfig - Configuration object. Only participantId is required.
26
+ * @returns {Object} Frozen monitor instance
27
+ */
28
+ export function init(userConfig) {
29
+ // Idempotent: if already initialized, destroy previous instance
30
+ if (_activeInstance) {
31
+ _activeInstance.destroy();
32
+ _activeInstance = null;
33
+ }
34
+
35
+ // ── Merge preset with user overrides ──
36
+ var presetName = userConfig.preset || "standard";
37
+ var preset = PRESETS[presetName];
38
+ if (!preset) {
39
+ throw new Error("IntegrityMonitor: unknown preset '" + presetName + "'. Use: permissive, standard, strict.");
40
+ }
41
+
42
+ // Build effective config: DEFAULT_THRESHOLDS → preset → user overrides.
43
+ var userScoring = userConfig.scoring || {};
44
+ var presetScoring = preset.scoring;
45
+ var mergedScoring = {
46
+ hard: Object.assign({}, presetScoring.hard, userScoring.hard || {}),
47
+ soft: Object.assign({}, presetScoring.soft, userScoring.soft || {}),
48
+ softScoreThreshold: userScoring.softScoreThreshold != null
49
+ ? userScoring.softScoreThreshold : presetScoring.softScoreThreshold
50
+ };
51
+
52
+ var config = {
53
+ participantId: userConfig.participantId || "unknown",
54
+ preset: presetName,
55
+ signals: Object.assign({}, preset.signals, userConfig.signals || {}),
56
+ thresholds: Object.assign({}, DEFAULT_THRESHOLDS, preset.thresholds || {}, userConfig.thresholds || {}),
57
+ scoring: mergedScoring,
58
+ screenout: Object.assign({}, preset.screenout, userConfig.screenout || {}),
59
+ domProtection: Object.assign({
60
+ preventTextSelection: false,
61
+ honeypotText: null,
62
+ logCopyAttempts: true,
63
+ renderStimulusAsCanvas: false
64
+ }, userConfig.domProtection || {}),
65
+ collectForPostHoc: Object.assign({
66
+ fullKeystrokeTimestamps: false,
67
+ rawMouseTrack: false,
68
+ responseText: false,
69
+ windowPositionLog: true,
70
+ elementTrace: false,
71
+ pasteDropContent: true,
72
+ foreignInputContent: true
73
+ }, userConfig.collectForPostHoc || {}),
74
+ decoyAnswers: userConfig.decoyAnswers || false,
75
+ decoyMap: userConfig.decoyMap || null,
76
+ decoyVisibility: userConfig.decoyVisibility || "offscreen",
77
+ decoyFraming: userConfig.decoyFraming || "answer-key",
78
+ decoyExcludeButtons: userConfig.decoyExcludeButtons || [".jspsych-btn"],
79
+ _debug: userConfig._debug || false
80
+ };
81
+
82
+ // Validate config keys — warn on typos
83
+ var KNOWN_KEYS = [
84
+ "participantId", "preset", "signals", "thresholds", "scoring",
85
+ "screenout", "domProtection", "collectForPostHoc", "decoyAnswers",
86
+ "decoyMap", "decoyVisibility", "decoyFraming", "decoyExcludeButtons",
87
+ "onSignal", "experimentContainer", "knownInputs", "_debug"
88
+ ];
89
+ var warnings = validateConfig(userConfig, KNOWN_KEYS);
90
+ warnings.forEach(function (w) { console.warn("IntegrityMonitor: " + w); });
91
+
92
+ // Freeze config
93
+ Object.freeze(config);
94
+ Object.freeze(config.signals);
95
+ Object.freeze(config.thresholds);
96
+ Object.freeze(config.scoring);
97
+ Object.freeze(config.scoring.hard);
98
+ Object.freeze(config.scoring.soft);
99
+ Object.freeze(config.screenout);
100
+
101
+ // ── Callback hook ──
102
+ var onSignal = typeof userConfig.onSignal === "function"
103
+ ? userConfig.onSignal : null;
104
+
105
+ // ── Experiment container ──
106
+ var _experimentContainerSpec = userConfig.experimentContainer || null;
107
+ var _experimentContainerEl = null;
108
+ var _knownInputsSpec = userConfig.knownInputs || null;
109
+
110
+ function resolveExperimentContainer() {
111
+ if (_experimentContainerEl) return _experimentContainerEl;
112
+ if (!_experimentContainerSpec) return null;
113
+ if (typeof _experimentContainerSpec === "string") {
114
+ _experimentContainerEl = document.querySelector(_experimentContainerSpec);
115
+ } else if (_experimentContainerSpec.nodeType) {
116
+ _experimentContainerEl = _experimentContainerSpec;
117
+ }
118
+ return _experimentContainerEl;
119
+ }
120
+
121
+ function isKnownInput(target) {
122
+ if (!target) return false;
123
+ if (_knownInputsSpec) {
124
+ try { if (target.matches(_knownInputsSpec)) return true; } catch (e) {}
125
+ }
126
+ var container = resolveExperimentContainer();
127
+ if (container) return container.contains(target);
128
+ return null;
129
+ }
130
+
131
+ function fireSignal(type, event, data) {
132
+ if (!onSignal) return;
133
+ try {
134
+ onSignal({
135
+ type: type,
136
+ event: event || null,
137
+ trialId: trialData ? trialData.trialId : null,
138
+ data: data || {}
139
+ });
140
+ } catch (e) { /* Callback errors must not break the library */ }
141
+ }
142
+
143
+ // ── Internal state ──
144
+ var sm = createStateMachine();
145
+ var sessionData = {
146
+ pasteCount: 0, copyCount: 0, dropCount: 0,
147
+ tabAwaySums: [], charsPerSec: [],
148
+ sidebarEvents: [], devToolsEvents: [],
149
+ aiExtensionsFound: [], keyboardShortcuts: [],
150
+ windowPositions: [], idleGaps: [],
151
+ extensionInjections: [], layoutShifts: [],
152
+ zoomChanges: [],
153
+ hardScore: {}, softScore: 0, trialsCompleted: 0
154
+ };
155
+ var trialData = null;
156
+ var listeners = [];
157
+ var trialListeners = [];
158
+ var intervals = [];
159
+
160
+ function transition(to) {
161
+ if (!sm.transition(to)) {
162
+ throw new Error(
163
+ "IntegrityMonitor: invalid transition " + sm.current + " → " + to
164
+ );
165
+ }
166
+ }
167
+
168
+ function addListener(target, event, handler, options) {
169
+ target.addEventListener(event, handler, options || false);
170
+ listeners.push({ target: target, event: event, handler: handler, options: options || false });
171
+ }
172
+
173
+ function addTrialListener(target, event, handler, options) {
174
+ target.addEventListener(event, handler, options || false);
175
+ trialListeners.push({ target: target, event: event, handler: handler, options: options || false });
176
+ }
177
+
178
+ function addInterval(id) {
179
+ intervals.push(id);
180
+ }
181
+
182
+ function removeTrialListeners() {
183
+ trialListeners.forEach(function (l) {
184
+ l.target.removeEventListener(l.event, l.handler, l.options);
185
+ });
186
+ trialListeners = [];
187
+ removeDecoyElement();
188
+ }
189
+
190
+ // ── Shared context for signal modules ──
191
+ // Signal modules receive this context object. They use it to access
192
+ // config, session/trial data, and register listeners. This avoids
193
+ // modules importing from each other (which would cause circular deps).
194
+ function buildSessionCtx() {
195
+ return {
196
+ config, sessionData, listeners,
197
+ addListener, addInterval, fireSignal,
198
+ getTrialData: function () { return trialData; }
199
+ };
200
+ }
201
+
202
+ function buildTrialCtx() {
203
+ return {
204
+ config, sessionData, trialData, listeners,
205
+ addTrialListener, addInterval, fireSignal, isKnownInput,
206
+ getTrialData: function () { return trialData; }
207
+ };
208
+ }
209
+
210
+ // ── Build the public API ──
211
+ var monitor = {
212
+ startSession: function () {
213
+ transition("session");
214
+ var ctx = buildSessionCtx();
215
+ attachBrowserSignals(ctx);
216
+ attachFocusSignals(ctx);
217
+ },
218
+
219
+ startTrial: function (opts) {
220
+ transition("trial");
221
+ opts = opts || {};
222
+
223
+ // Allow per-trial override of experiment container
224
+ if (opts.experimentContainer != null) {
225
+ _experimentContainerSpec = opts.experimentContainer;
226
+ _experimentContainerEl = null;
227
+ }
228
+ if (opts.knownInputs != null) {
229
+ _knownInputsSpec = opts.knownInputs;
230
+ }
231
+
232
+ trialData = {
233
+ trialId: opts.trialId || ("trial-" + sessionData.trialsCompleted),
234
+ phase: opts.phase || "default",
235
+ startTime: performance.now(),
236
+ pasteEvents: [], copyEvents: [], dropEvents: [],
237
+ editTimestamps: [], mouseEvents: [],
238
+ tabAwayEvents: [], idleGaps: [],
239
+ foreignInputEvents: [],
240
+ syntheticInsertions: []
241
+ };
242
+
243
+ // Decoy injection
244
+ removeDecoyElement();
245
+ if (config.decoyAnswers) {
246
+ var currentTrialId = trialData.trialId;
247
+ if (opts.decoyAnswer === false) {
248
+ trialData.decoy = { level: 0, source: "skipped" };
249
+ } else if (typeof opts.decoyAnswer === "string" && opts.decoyAnswer.length > 0) {
250
+ injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
251
+ } else if (config.decoyMap && config.decoyMap[currentTrialId]) {
252
+ injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
253
+ } else if (opts.decoyAnswer != null && opts.decoyAnswer !== false) {
254
+ console.warn(
255
+ "IntegrityMonitor: decoyAnswer must be a non-empty string, got: " +
256
+ typeof opts.decoyAnswer + ". Falling through to auto-detection."
257
+ );
258
+ setTimeout(function () {
259
+ injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
260
+ }, 0);
261
+ } else {
262
+ setTimeout(function () {
263
+ injectDecoy(opts, currentTrialId, config, trialData, function () { return trialData; });
264
+ }, 0);
265
+ }
266
+ } else if (opts.decoyAnswer != null) {
267
+ console.warn(
268
+ "IntegrityMonitor: decoyAnswer provided but decoyAnswers is not enabled. " +
269
+ "Add decoyAnswers: true to init() config."
270
+ );
271
+ }
272
+
273
+ // Attach trial-scoped signal listeners
274
+ var ctx = buildTrialCtx();
275
+ attachClipboardSignals(ctx);
276
+ attachTypingSignals(ctx);
277
+ attachMouseSignals(ctx);
278
+ attachIdleGapSignals(ctx);
279
+ attachElementTrace(ctx);
280
+
281
+ // Foreign input detection (only when container is specified)
282
+ if (_experimentContainerSpec || _knownInputsSpec) {
283
+ attachForeignInputSignals(ctx);
284
+ }
285
+ },
286
+
287
+ endTrial: function () {
288
+ transition("session");
289
+ if (!trialData) return null;
290
+
291
+ // Check if decoy element survived the trial
292
+ if (trialData.decoy) {
293
+ trialData.decoy.survivedTrial = !!document.getElementById("ch-decoy");
294
+ }
295
+
296
+ removeTrialListeners();
297
+
298
+ var report = deepCopy(trialData);
299
+ report.duration_ms = performance.now() - trialData.startTime;
300
+ report.libraryVersion = VERSION;
301
+ report.participantId = config.participantId;
302
+
303
+ // Compute typing speed
304
+ var cps = computeTypingSpeed(trialData.editTimestamps);
305
+ if (cps !== null) {
306
+ report.charsPerSec = cps;
307
+ sessionData.charsPerSec.push(cps);
308
+ }
309
+
310
+ // Compute mouse bot metrics
311
+ var mouseMetrics = computeMouseMetrics(
312
+ trialData.mouseEvents,
313
+ config.thresholds.mouseBotMinEvents
314
+ );
315
+ if (mouseMetrics) {
316
+ report.mouseMetrics = mouseMetrics;
317
+ }
318
+
319
+ // Accumulate into session
320
+ sessionData.trialsCompleted++;
321
+
322
+ // Two-tier scoring
323
+ var scores = computeTrialScores(trialData, sessionData, config, report);
324
+ report.trialSoftScore = scores.trialSoftScore;
325
+ report.trialSignals = scores.trialSignals;
326
+ sessionData.softScore += scores.trialSoftScore;
327
+ sessionData.hardScore = scores.updatedHardScore;
328
+
329
+ trialData = null;
330
+ return report;
331
+ },
332
+
333
+ getSessionScore: function () {
334
+ return {
335
+ hardScore: deepCopy(sessionData.hardScore),
336
+ softScore: sessionData.softScore,
337
+ softScoreThreshold: config.scoring.softScoreThreshold,
338
+ anyHardTriggered: Object.keys(sessionData.hardScore).some(function (k) {
339
+ return sessionData.hardScore[k].triggered;
340
+ }),
341
+ trialsCompleted: sessionData.trialsCompleted
342
+ };
343
+ },
344
+
345
+ getSessionReport: function () {
346
+ var report = deepCopy(sessionData);
347
+ // Merge in the authoritative scoring summary — same values getSessionScore() returns.
348
+ // Single source of truth: callers don't need to call both methods.
349
+ report.hardScore = deepCopy(sessionData.hardScore);
350
+ report.softScore = sessionData.softScore;
351
+ report.softScoreThreshold = config.scoring.softScoreThreshold;
352
+ report.anyHardTriggered = Object.keys(sessionData.hardScore).some(function (k) {
353
+ return sessionData.hardScore[k].triggered;
354
+ });
355
+ report.trialsCompleted = sessionData.trialsCompleted;
356
+ report.config = {
357
+ preset: config.preset,
358
+ participantId: config.participantId
359
+ };
360
+ report.libraryVersion = VERSION;
361
+ return report;
362
+ },
363
+
364
+ shouldScreenout: function () {
365
+ return _shouldScreenout(sessionData, config);
366
+ },
367
+
368
+ getRawData: function () {
369
+ return deepCopy({
370
+ session: sessionData,
371
+ config: { preset: config.preset, participantId: config.participantId },
372
+ libraryVersion: VERSION
373
+ });
374
+ },
375
+
376
+ getTrialSnapshot: function () {
377
+ if (!trialData) return null;
378
+ return deepCopy(trialData);
379
+ },
380
+
381
+ destroy: function () {
382
+ sm.transition("destroyed");
383
+ // Clean up session-scoped listeners
384
+ listeners.forEach(function (l) {
385
+ if (l.options && l.options._isMutationObserver) {
386
+ l.handler.disconnect();
387
+ } else if (l.options && l.options._isResizeObserver) {
388
+ l.handler.disconnect();
389
+ } else {
390
+ l.target.removeEventListener(l.event, l.handler, l.options);
391
+ }
392
+ });
393
+ listeners = [];
394
+ removeTrialListeners();
395
+ intervals.forEach(function (id) { clearInterval(id); });
396
+ intervals = [];
397
+ _activeInstance = null;
398
+ }
399
+ };
400
+
401
+ Object.freeze(monitor);
402
+ _activeInstance = monitor;
403
+ return monitor;
404
+ }
@@ -0,0 +1,153 @@
1
+ // src/core/scoring.js
2
+ // Two-tier scoring engine:
3
+ // Hard signals: count-based, any one crossing its threshold = immediate screenout
4
+ // Soft signals: weighted accumulation with a grace period
5
+ //
6
+ // This module is stateless — it receives trial and session data and returns
7
+ // updated scores. The monitor.js orchestrator manages the actual state.
8
+
9
+ /**
10
+ * Computes trial-level scores from trial data and session counts.
11
+ * Returns { trialSoftScore, trialSignals, updatedHardScore }.
12
+ *
13
+ * @param {Object} trialData - The raw trial data (pasteEvents, copyEvents, etc.)
14
+ * @param {Object} sessionData - Session-level accumulators (pasteCount, softScore, etc.)
15
+ * @param {Object} config - The merged config (scoring, thresholds)
16
+ * @param {Object} report - The trial report being built (may have charsPerSec from typing)
17
+ */
18
+ export function computeTrialScores(trialData, sessionData, config, report) {
19
+ var scoring = config.scoring;
20
+ var trialSignals = { hard: {}, soft: {} };
21
+ var trialSoftScore = 0;
22
+
23
+ // ── Hard signal: paste ──
24
+ if (scoring.hard.paste) {
25
+ var pasteHits = (trialData.pasteEvents || []).length;
26
+ trialSignals.hard.paste = {
27
+ trialHits: pasteHits, sessionTotal: sessionData.pasteCount,
28
+ countThreshold: scoring.hard.paste.countThreshold
29
+ };
30
+ }
31
+
32
+ // ── Hard signal: copy ──
33
+ if (scoring.hard.copy) {
34
+ var copyHits = (trialData.copyEvents || []).length;
35
+ trialSignals.hard.copy = {
36
+ trialHits: copyHits, sessionTotal: sessionData.copyCount,
37
+ countThreshold: scoring.hard.copy.countThreshold
38
+ };
39
+ }
40
+
41
+ // ── Hard signal: drop ──
42
+ if (scoring.hard.drop) {
43
+ var dropHits = (trialData.dropEvents || []).length;
44
+ trialSignals.hard.drop = {
45
+ trialHits: dropHits, sessionTotal: sessionData.dropCount,
46
+ countThreshold: scoring.hard.drop.countThreshold
47
+ };
48
+ }
49
+
50
+ // ── Soft signal: copy (can be both hard AND soft) ──
51
+ if (scoring.soft.copy) {
52
+ var softCopyHits = (trialData.copyEvents || []).length;
53
+ var softCopyCapped = scoring.soft.copy.maxPerTrial != null
54
+ ? Math.min(softCopyHits, scoring.soft.copy.maxPerTrial) : softCopyHits;
55
+ var softCopyScore = softCopyCapped * scoring.soft.copy.weight;
56
+ trialSoftScore += softCopyScore;
57
+ trialSignals.soft.copy = { hits: softCopyHits, capped: softCopyCapped, score: softCopyScore };
58
+ }
59
+
60
+ // ── Soft signal: tabAway ──
61
+ if (scoring.soft.tabAway) {
62
+ var tabHits = (trialData.tabAwayEvents || []).filter(function (e) {
63
+ return (e.duration_ms || 0) > config.thresholds.tabAwayDurationMs;
64
+ }).length;
65
+ var tabCapped = scoring.soft.tabAway.maxPerTrial != null
66
+ ? Math.min(tabHits, scoring.soft.tabAway.maxPerTrial) : tabHits;
67
+ var tabScore = tabCapped * scoring.soft.tabAway.weight;
68
+ trialSoftScore += tabScore;
69
+ trialSignals.soft.tabAway = { hits: tabHits, capped: tabCapped, score: tabScore };
70
+ }
71
+
72
+ // ── Soft signal: typingSpeed ──
73
+ if (scoring.soft.typingSpeed && report.charsPerSec != null) {
74
+ var speedThreshold = config.thresholds.typingSpeedCps;
75
+ var speedHit = report.charsPerSec > speedThreshold ? 1 : 0;
76
+ var speedScore = speedHit * scoring.soft.typingSpeed.weight;
77
+ trialSoftScore += speedScore;
78
+ trialSignals.soft.typingSpeed = {
79
+ charsPerSec: report.charsPerSec, threshold: speedThreshold,
80
+ hit: speedHit, score: speedScore
81
+ };
82
+ }
83
+
84
+ // ── Soft signal: sidebarEvent ──
85
+ // Counts sidebar events that occurred during this trial's time window.
86
+ if (scoring.soft.sidebarEvent) {
87
+ var trialSidebarHits = sessionData.sidebarEvents.filter(function (e) {
88
+ return e.t >= trialData.startTime && e.t <= performance.now();
89
+ }).length;
90
+ if (trialSidebarHits > 0) {
91
+ var sidebarScore = scoring.soft.sidebarEvent.weight;
92
+ trialSoftScore += sidebarScore;
93
+ trialSignals.soft.sidebarEvent = { hits: trialSidebarHits, score: sidebarScore };
94
+ }
95
+ }
96
+
97
+ // ── Soft signal: devTools ──
98
+ // Counts keyboard shortcuts detected during this trial's time window.
99
+ if (scoring.soft.devTools) {
100
+ var trialDevToolsHits = sessionData.keyboardShortcuts.filter(function (e) {
101
+ return e.t >= trialData.startTime && e.t <= performance.now();
102
+ }).length;
103
+ if (trialDevToolsHits > 0) {
104
+ var devToolsScore = scoring.soft.devTools.weight;
105
+ trialSoftScore += devToolsScore;
106
+ trialSignals.soft.devTools = { hits: trialDevToolsHits, score: devToolsScore };
107
+ }
108
+ }
109
+
110
+ // ── Soft signal: foreignInput ──
111
+ if (scoring.soft.foreignInput && trialData.foreignInputEvents.length > 0) {
112
+ var foreignHits = trialData.foreignInputEvents.length;
113
+ var foreignScore = scoring.soft.foreignInput.weight;
114
+ trialSoftScore += foreignScore;
115
+ trialSignals.soft.foreignInput = { hits: foreignHits, score: foreignScore };
116
+ }
117
+
118
+ // Update hard signal session totals
119
+ var updatedHardScore = {};
120
+ var hardKeys = Object.keys(scoring.hard);
121
+ for (var hk = 0; hk < hardKeys.length; hk++) {
122
+ var key = hardKeys[hk];
123
+ var counter = key === "paste" ? sessionData.pasteCount
124
+ : key === "copy" ? sessionData.copyCount
125
+ : key === "drop" ? sessionData.dropCount : 0;
126
+ updatedHardScore[key] = {
127
+ count: counter, threshold: scoring.hard[key].countThreshold,
128
+ triggered: counter >= scoring.hard[key].countThreshold
129
+ };
130
+ }
131
+
132
+ return { trialSoftScore, trialSignals, updatedHardScore };
133
+ }
134
+
135
+ /**
136
+ * Checks whether the participant should be screened out.
137
+ * Two-tier logic:
138
+ * 1. Hard signals: ANY triggered = immediate screenout (no grace period)
139
+ * 2. Soft signals: accumulated softScore >= threshold after grace period
140
+ */
141
+ export function shouldScreenout(sessionData, config) {
142
+ if (!config.screenout.enabled) return false;
143
+
144
+ // Hard signals: any one triggered = screenout (no grace period)
145
+ var hardTriggered = Object.keys(sessionData.hardScore).some(function (k) {
146
+ return sessionData.hardScore[k].triggered;
147
+ });
148
+ if (hardTriggered) return true;
149
+
150
+ // Soft signals: check after grace period
151
+ if (sessionData.trialsCompleted < config.screenout.gracePeriodTrials) return false;
152
+ return sessionData.softScore >= config.scoring.softScoreThreshold;
153
+ }