cyborg-hunter 0.5.0 → 0.7.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +78 -21
  5. package/package.json +10 -3
  6. package/src/cli/analyzers/edge-exit.js +4 -1
  7. package/src/cli/analyzers/phase-scope.js +83 -0
  8. package/src/cli/analyzers/summary.js +161 -28
  9. package/src/cli/analyzers/triage.js +59 -27
  10. package/src/cli/config.js +26 -1
  11. package/src/cli/ingest.js +623 -41
  12. package/src/cli/init.js +1 -1
  13. package/src/cli/renderers/event-log.js +18 -19
  14. package/src/cli/renderers/extensions.js +12 -3
  15. package/src/cli/renderers/html-index.js +163 -24
  16. package/src/cli/renderers/replay-assets.js +177 -0
  17. package/src/cli/renderers/replay-viewer.client.js +1022 -0
  18. package/src/cli/renderers/session-timeline.js +917 -0
  19. package/src/cli/renderers/summary-csv.js +5 -0
  20. package/src/cli/renderers/trajectories.js +68 -7
  21. package/src/cli/renderers/triage-md.js +10 -4
  22. package/src/cli/renderers/typing-profile.js +7 -1
  23. package/src/cli/report.js +42 -8
  24. package/src/core/monitor.js +60 -7
  25. package/src/core/scoring.js +11 -2
  26. package/src/core/signals/browser.js +51 -18
  27. package/src/core/signals/clipboard.js +10 -2
  28. package/src/core/signals/dom-protection.js +9 -0
  29. package/src/core/signals/focus.js +16 -2
  30. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  31. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  32. package/src/jspsych/extension-guard-friction.js +32 -12
  33. package/src/jspsych/extension-guard-honeypot.js +25 -1
  34. package/src/replay/capture-dom.js +575 -0
  35. package/src/replay/capture-trace.js +468 -0
  36. package/src/replay/index.js +104 -0
  37. package/src/replay/persistence.js +141 -0
  38. package/src/replay/recorder.js +315 -0
  39. package/src/replay/serializer.js +119 -0
  40. package/src/shared/constants.js +12 -6
  41. package/src/shared/schema.js +5 -0
  42. package/src/shared/validation.js +55 -0
  43. package/dist/cyborg-hunter.esm.js +0 -1527
  44. package/dist/cyborg-hunter.min.js +0 -6
  45. package/dist/extension-cyborg-hunter.js +0 -1
  46. package/dist/extension-guard-friction.js +0 -36
  47. package/dist/extension-guard-honeypot.js +0 -1
  48. package/src/cli/renderers/tab-timeline.js +0 -149
@@ -0,0 +1,135 @@
1
+ // jsPsych extension adapter for the cyborg-hunter session replay recorder.
2
+ // Bundled SELF-CONTAINED (imports the replay core), following the guard-
3
+ // extension precedent: one dist file provides both window.CyborgHunterReplay
4
+ // (standalone use) and window.jsPsychCyborgHunterReplay (jsPsych use).
5
+ //
6
+ // Wiring (order matters at finalize; declaration order is free):
7
+ //
8
+ // const jsPsych = initJsPsych({
9
+ // extensions: [
10
+ // { type: jsPsychCyborgHunter, params: { participantId, preset } },
11
+ // { type: jsPsychGuardFriction },
12
+ // { type: jsPsychGuardHoneypot },
13
+ // { type: jsPsychCyborgHunterReplay, params: {
14
+ // participantId, tier: 'dom',
15
+ // autoSave: { mode: 'datapipe', experimentId: 'ABC123' } } }
16
+ // ],
17
+ // on_finish: async function () {
18
+ // jsPsych.extensions['guard-friction'].finalize();
19
+ // jsPsych.extensions['guard-honeypot'].finalize();
20
+ // jsPsych.extensions['cyborg-hunter'].finalize();
21
+ // await jsPsych.extensions['cyborg-hunter-replay'].finalize();
22
+ // jsPsych.data.get().localSave('csv', 'data.csv');
23
+ // }
24
+ // });
25
+ //
26
+ // Replay finalizes LAST: it pulls CH's session report through a monitor
27
+ // reference stashed at initialize() — never through window.CyborgHunter,
28
+ // whose singleton slot is nulled by CH's destroy(). getSessionReport()
29
+ // remains callable after destroy() (sessionData survives teardown), so the
30
+ // pull works even though CH finalized first.
31
+
32
+ import * as CHReplay from '../replay/index.js';
33
+ import { buildReplayMeta } from '../replay/persistence.js';
34
+
35
+ class CyborgHunterReplayExtension {
36
+ static info = {
37
+ name: 'cyborg-hunter-replay',
38
+ version: '0.7.0',
39
+ data: {} // per-trial return is {}; session meta goes via addProperties
40
+ };
41
+
42
+ constructor(jsPsych) {
43
+ this.jsPsych = jsPsych;
44
+ this.api = null;
45
+ this._chMonitor = null;
46
+ this._lastRecording = null;
47
+ this._monitoring = false;
48
+ }
49
+
50
+ initialize(params) {
51
+ this.params = params || {};
52
+ this.api = CHReplay.attach(this.params);
53
+ this.api.startSession();
54
+ this._stashChMonitor();
55
+ }
56
+
57
+ // The cyborg-hunter extension initializes before this one when declared
58
+ // earlier in the extensions array; if not, retry the stash at finalize.
59
+ _stashChMonitor() {
60
+ try {
61
+ var ch = this.jsPsych && this.jsPsych.extensions &&
62
+ this.jsPsych.extensions['cyborg-hunter'];
63
+ if (ch && ch.monitor) this._chMonitor = ch.monitor;
64
+ } catch (e) { /* standalone replay is fine */ }
65
+ }
66
+
67
+ // jsPsych 7 calls on_start unconditionally for every trial that lists the
68
+ // extension — must exist even as a no-op (see extension-cyborg-hunter.js).
69
+ on_start(_params) {}
70
+
71
+ on_load(params) {
72
+ if (!this.api) return;
73
+ this._monitoring = true;
74
+ var trialIndex = 0;
75
+ var plugin = 'unknown';
76
+ try { trialIndex = this.jsPsych.getProgress().current_trial_global; } catch (e) {}
77
+ try {
78
+ plugin = (this.jsPsych.getCurrentTrial() || {}).type?.info?.name || 'unknown';
79
+ } catch (e) {}
80
+ this.api.startTrial({
81
+ trialId: (params && params.trialId) || 'trial-' + trialIndex,
82
+ plugin: plugin
83
+ });
84
+ }
85
+
86
+ on_finish(_params) {
87
+ if (this.api && this._monitoring) {
88
+ this._monitoring = false;
89
+ this.api.endTrial();
90
+ }
91
+ return {};
92
+ }
93
+
94
+ // Called manually from the experiment's on_finish, AFTER the other three
95
+ // extensions (see header). Serializes, autosaves, attaches the meta
96
+ // pointer. Never throws — the experiment's save path must always run.
97
+ async finalize() {
98
+ if (!this.api) return;
99
+ try {
100
+ try { this.api.stopSession('finished'); } catch (e) { /* already stopped */ }
101
+ if (!this._chMonitor) this._stashChMonitor();
102
+ var chReport = null;
103
+ try {
104
+ chReport = this._chMonitor ? this._chMonitor.getSessionReport() : null;
105
+ } catch (e) {
106
+ console.warn('[cyborg-hunter-replay] could not pull CH session report:', e);
107
+ }
108
+ var result = await this.api.autoSaveNow({ chSessionReport: chReport });
109
+ this._lastRecording = result.recording;
110
+ this.jsPsych.data.addProperties({ integrityReplayMeta: result.meta });
111
+ } catch (e) {
112
+ console.warn('[cyborg-hunter-replay] finalize failed:', e);
113
+ try {
114
+ this.jsPsych.data.addProperties({
115
+ replayFinalizeError: String(e && e.message ? e.message : e)
116
+ });
117
+ } catch (_) { /* nothing left to do */ }
118
+ }
119
+ try { this.api.destroy(); } catch (e) { /* teardown best-effort */ }
120
+ // Null the handle so a second finalize() (e.g. duplicated on_finish
121
+ // wiring) is a clean no-op instead of re-serializing and re-saving.
122
+ this.api = null;
123
+ }
124
+
125
+ // Debug/test access to the finalized recording (also handy in the console
126
+ // during piloting: jsPsych.extensions['cyborg-hunter-replay'].getLastRecording()).
127
+ getLastRecording() {
128
+ return this._lastRecording;
129
+ }
130
+ }
131
+
132
+ export { CyborgHunterReplayExtension };
133
+ if (typeof window !== 'undefined') {
134
+ window.jsPsychCyborgHunterReplay = CyborgHunterReplayExtension;
135
+ }
@@ -10,7 +10,7 @@ class CyborgHunterExtension {
10
10
  // and package.json). Hand-bumped on each release; if you forget, the
11
11
  // jsPsych developer console shows a stale number — the actual version
12
12
  // attached to data is read from window.CyborgHunter.VERSION at runtime.
13
- version: '0.3.0',
13
+ version: '0.7.0',
14
14
  data: { integrity: { type: 'object' } }
15
15
  };
16
16
 
@@ -71,7 +71,10 @@ class CyborgHunterExtension {
71
71
  this.monitor.startTrial({
72
72
  trialId: (params && params.trialId) || `trial-${trialIndex}`,
73
73
  phase: params?.phase || null,
74
- decoyAnswer: params?.decoyAnswer || null,
74
+ // Nullish coalescing (not ||) so an explicit `decoyAnswer: false` — the
75
+ // per-trial "skip the decoy" opt-out — reaches the core as false. With
76
+ // `|| null` it became null, and the core auto-generated a decoy instead.
77
+ decoyAnswer: params?.decoyAnswer ?? null,
75
78
  experimentContainer: params?.experimentContainer || null
76
79
  });
77
80
  }
@@ -154,6 +157,10 @@ class CyborgHunterExtension {
154
157
  sidebarEvents, keyboardShortcuts, windowPositions,
155
158
  layoutShifts, zoomChanges, idleGaps, extensionInjections,
156
159
  tabAwaySums, charsPerSec, aiExtensionsFound,
160
+ // Persist the effective runtime settings (preset + thresholds the CLI
161
+ // consumes) so the report can interpret this participant with the
162
+ // thresholds the library actually used, not analyst-side defaults.
163
+ config,
157
164
  ...extras
158
165
  },
159
166
  integrityScore: { hardScore, softScore, anyHardTriggered, trialsCompleted, softScoreThreshold },
@@ -56,6 +56,22 @@
56
56
  //
57
57
  // ============================================================
58
58
 
59
+ // Prefix-aware fullscreen-element lookup. Safari <16.4 and some iOS WebViews
60
+ // expose ONLY document.webkitFullscreenElement (and fire 'webkitfullscreenchange');
61
+ // older Firefox used the moz-prefixed API. requestFullscreen() below already
62
+ // falls through these prefixes, so the authoritative check()/start()/settling
63
+ // gates MUST too — otherwise a participant who successfully enters fullscreen on
64
+ // a prefix-only browser reads a null unprefixed element and is trapped behind a
65
+ // permanent false 'not_fullscreen' violation. Pure + exported for unit testing.
66
+ export function fullscreenElementOf(doc) {
67
+ doc = doc || (typeof document !== 'undefined' ? document : null);
68
+ if (!doc) return null;
69
+ return doc.fullscreenElement
70
+ || doc.webkitFullscreenElement
71
+ || doc.mozFullScreenElement
72
+ || null;
73
+ }
74
+
59
75
  (function (global) {
60
76
  'use strict';
61
77
 
@@ -143,10 +159,7 @@
143
159
  }
144
160
 
145
161
  function getDiagnostics() {
146
- const fsEl = document.fullscreenElement
147
- || document.webkitFullscreenElement
148
- || document.mozFullScreenElement
149
- || null;
162
+ const fsEl = fullscreenElementOf(document);
150
163
  const baselineDeltaCssPx = computeBaselineDeltaCssPx();
151
164
 
152
165
  return {
@@ -487,7 +500,7 @@
487
500
  function check() {
488
501
  const diagnostics = getDiagnostics();
489
502
 
490
- if (!document.fullscreenElement) {
503
+ if (!fullscreenElementOf(document)) {
491
504
  return { ok: false, reason: 'not_fullscreen', diagnostics: diagnostics };
492
505
  }
493
506
  if (document.visibilityState !== 'visible') {
@@ -689,7 +702,7 @@
689
702
  // re-capture baseline against the now-settled DPR. Fixes
690
703
  // a Chrome-on-Retina case where DPR is briefly stale on
691
704
  // the frame where fullscreenchange fires.
692
- if (document.fullscreenElement && performance.now() < state.rebaselineUntil) {
705
+ if (fullscreenElementOf(document) && performance.now() < state.rebaselineUntil) {
693
706
  state.rebaselineUntil = 0;
694
707
  captureBaseline(function () { update('event.resize.rebaseline'); });
695
708
  return;
@@ -699,6 +712,11 @@
699
712
  };
700
713
 
701
714
  _addEventListener.call(document, 'fullscreenchange', state.eventHandlers.fullscreenchange);
715
+ // Prefixed variants for browsers that only fire the vendor event
716
+ // (Safari <16.4, some iOS WebViews, older Firefox). Same handler, so
717
+ // baseline capture / re-check happens regardless of which one fires.
718
+ _addEventListener.call(document, 'webkitfullscreenchange', state.eventHandlers.fullscreenchange);
719
+ _addEventListener.call(document, 'mozfullscreenchange', state.eventHandlers.fullscreenchange);
702
720
  _addEventListener.call(document, 'visibilitychange', state.eventHandlers.visibilitychange);
703
721
  _addEventListener.call(window, 'blur', state.eventHandlers.blur);
704
722
  _addEventListener.call(window, 'focus', state.eventHandlers.focus);
@@ -712,6 +730,8 @@
712
730
 
713
731
  if (state.eventHandlers) {
714
732
  _removeEventListener.call(document, 'fullscreenchange', state.eventHandlers.fullscreenchange);
733
+ _removeEventListener.call(document, 'webkitfullscreenchange', state.eventHandlers.fullscreenchange);
734
+ _removeEventListener.call(document, 'mozfullscreenchange', state.eventHandlers.fullscreenchange);
715
735
  _removeEventListener.call(document, 'visibilitychange', state.eventHandlers.visibilitychange);
716
736
  _removeEventListener.call(window, 'blur', state.eventHandlers.blur);
717
737
  _removeEventListener.call(window, 'focus', state.eventHandlers.focus);
@@ -724,7 +744,7 @@
724
744
  }
725
745
 
726
746
  function onFullscreenChange() {
727
- if (document.fullscreenElement) {
747
+ if (fullscreenElementOf(document)) {
728
748
  captureBaseline();
729
749
  // Open a brief window during which the next resize re-captures
730
750
  // baseline. Chrome reports a stale devicePixelRatio on the
@@ -802,7 +822,7 @@
802
822
  state.tamperHandle = _setInterval(tamperCheck, TAMPER_CHECK_MS);
803
823
  logDebug('tamper_poll.started', { interval_ms: TAMPER_CHECK_MS });
804
824
  }
805
- if (document.fullscreenElement) {
825
+ if (fullscreenElementOf(document)) {
806
826
  captureBaseline(function () { update('start'); });
807
827
  } else {
808
828
  update('start');
@@ -1054,10 +1074,10 @@
1054
1074
  class GuardFrictionExtension {
1055
1075
  static info = {
1056
1076
  name: 'guard-friction',
1057
- // Hand-bumped on each release. The actual library version is whatever
1058
- // plugin-guard-friction.js exports at runtime; this number is for the
1059
- // jsPsych developer console only.
1060
- version: '0.4.0',
1077
+ // Hand-bumped on each release to track the package version
1078
+ // (package.json / src/shared/constants.js). Shown in the jsPsych developer
1079
+ // console only; the runtime library version is independent.
1080
+ version: '0.6.0',
1061
1081
  data: {}
1062
1082
  };
1063
1083
 
@@ -160,6 +160,8 @@
160
160
  // ----- Hidden form fields (catch full-DOM scrapes) -----
161
161
  const honeypot = _createElement.call(document, 'div');
162
162
  honeypot.id = 'fg-honeypot';
163
+ // Marks bait DOM for replay/DOM serializers to exclude ([data-ch-role]).
164
+ honeypot.setAttribute('data-ch-role', 'honeypot');
163
165
  Object.assign(honeypot.style, {
164
166
  position: 'absolute',
165
167
  width: '0',
@@ -199,6 +201,8 @@
199
201
  // doesn't change.
200
202
  const baitButton = _createElement.call(document, 'button');
201
203
  baitButton.id = 'fg-ai-bait-button';
204
+ // Marks bait DOM for replay/DOM serializers to exclude ([data-ch-role]).
205
+ baitButton.setAttribute('data-ch-role', 'honeypot');
202
206
  baitButton.type = 'button';
203
207
  baitButton.tabIndex = -1;
204
208
  baitButton.setAttribute('aria-label', BAIT_BUTTON_LABEL);
@@ -221,6 +225,8 @@
221
225
  const baitInput = _createElement.call(document, 'input');
222
226
  baitInput.type = 'text';
223
227
  baitInput.id = 'fg-ai-bait-input';
228
+ // Marks bait DOM for replay/DOM serializers to exclude ([data-ch-role]).
229
+ baitInput.setAttribute('data-ch-role', 'honeypot');
224
230
  baitInput.name = 'fg-ai-bait-input';
225
231
  baitInput.tabIndex = -1;
226
232
  baitInput.setAttribute('aria-label', BAIT_INPUT_LABEL);
@@ -285,12 +291,30 @@
285
291
  }
286
292
  }
287
293
 
294
+ function resetBaitFields() {
295
+ const aiUseEl = document.getElementById('fg-ai-use');
296
+ const aiReportEl = document.getElementById('fg-ai-report');
297
+ if (aiUseEl) aiUseEl.checked = false;
298
+ if (aiReportEl) aiReportEl.value = '';
299
+ }
300
+
288
301
  function init(opts) {
289
302
  opts = opts || {};
290
303
  if (opts.jsPsych) state.jsPsych = opts.jsPsych;
291
304
  if (typeof opts.debug === 'boolean') state.debugEnabled = opts.debug;
292
305
 
306
+ // Reset per-run forensic state. init() runs once per experiment, before
307
+ // any trial, so a second init() means a fresh run (jsPsych preview,
308
+ // restart, or two experiments sharing a tab) — it must NOT inherit the
309
+ // prior run's violation evidence or AI disclosures. injectHoneypotDOM()
310
+ // is idempotent (guards on state.injected), so on a re-init the old bait
311
+ // fields survive with their stale values; clear them explicitly.
312
+ state.violations = [];
313
+ state.currentViolation = null;
314
+ state.trialViolationStartIdx = 0;
315
+
293
316
  injectHoneypotDOM();
317
+ resetBaitFields();
294
318
 
295
319
  const friction = opts.friction || global.GuardFriction;
296
320
  if (friction && typeof friction.onViolation === 'function') {
@@ -418,7 +442,7 @@
418
442
  class GuardHoneypotExtension {
419
443
  static info = {
420
444
  name: 'guard-honeypot',
421
- version: '0.4.0',
445
+ version: '0.6.0',
422
446
  // Per-trial fields written by on_finish. Reflect violations for this
423
447
  // trial only; jsPsych spreads them onto each trial row (CSV columns).
424
448
  // Session totals are written as global properties by finalize().