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.
- package/CHANGELOG.md +110 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +78 -21
- package/package.json +10 -3
- package/src/cli/analyzers/edge-exit.js +4 -1
- package/src/cli/analyzers/phase-scope.js +83 -0
- package/src/cli/analyzers/summary.js +161 -28
- package/src/cli/analyzers/triage.js +59 -27
- package/src/cli/config.js +26 -1
- package/src/cli/ingest.js +623 -41
- package/src/cli/init.js +1 -1
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index.js +163 -24
- package/src/cli/renderers/replay-assets.js +177 -0
- package/src/cli/renderers/replay-viewer.client.js +1022 -0
- package/src/cli/renderers/session-timeline.js +917 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories.js +68 -7
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile.js +7 -1
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +60 -7
- package/src/core/scoring.js +11 -2
- package/src/core/signals/browser.js +51 -18
- package/src/core/signals/clipboard.js +10 -2
- package/src/core/signals/dom-protection.js +9 -0
- package/src/core/signals/focus.js +16 -2
- package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
- package/src/jspsych/extension-cyborg-hunter.js +9 -2
- package/src/jspsych/extension-guard-friction.js +32 -12
- package/src/jspsych/extension-guard-honeypot.js +25 -1
- package/src/replay/capture-dom.js +575 -0
- package/src/replay/capture-trace.js +468 -0
- package/src/replay/index.js +104 -0
- package/src/replay/persistence.js +141 -0
- package/src/replay/recorder.js +315 -0
- package/src/replay/serializer.js +119 -0
- package/src/shared/constants.js +12 -6
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- package/dist/cyborg-hunter.esm.js +0 -1527
- package/dist/cyborg-hunter.min.js +0 -6
- package/dist/extension-cyborg-hunter.js +0 -1
- package/dist/extension-guard-friction.js +0 -36
- package/dist/extension-guard-honeypot.js +0 -1
- 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.
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
version: '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.
|
|
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().
|