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,162 @@
1
+ // src/shared/constants.js
2
+ // Default thresholds and preset configurations for Cyborg Hunter.
3
+ // Single source of truth — used by both the browser library and CLI report tool.
4
+
5
+ export const VERSION = "0.3.0";
6
+
7
+ // Default detection thresholds shared across presets.
8
+ // Researchers can override any value at init() time.
9
+ export const DEFAULT_THRESHOLDS = {
10
+ pasteMinChars: 0, // record ALL pastes (was 20; now 0 by default)
11
+ dropMinChars: 0, // record ALL drops
12
+ sidebarGapPx: 100, // outerWidth - innerWidth above this = sidebar
13
+ layoutCompressionPx: 20, // innerWidth - clientWidth above scrollbar width
14
+ syntheticGapMs: 100, // keydown-to-input gap above this = synthetic insertion
15
+ idleGapMs: 10000, // no input for this long = idle gap
16
+ idleCheckIntervalMs: 5000, // how often to check for idle gaps
17
+ mouseThrottleMs: 50, // 20 Hz mouse sampling
18
+ mouseMaxEvents: 2000, // safety cap per trial (configurable)
19
+ mouseBotMinEvents: 3, // minimum move events for bot metrics
20
+ sidebarPollMs: 2000, // sidebar + zoom check interval
21
+ extensionScanMs: 30000, // AI extension DOM re-scan interval
22
+ windowPositionPollMs: 2000, // screenX/screenY polling interval
23
+ elementTraceHz: 500, // elementsFromPoint sampling interval (ms)
24
+ tabAwayDurationMs: 3000, // tab-away longer than this counts toward soft score (and is classified as "long" in reports)
25
+ typingSpeedCps: 10 // chars/sec above this counts toward soft score
26
+ };
27
+
28
+ // Named detection + scoring configurations.
29
+ // Architecture:
30
+ // signals — what to COLLECT (always-on by default; disable for GDPR-sensitive signals)
31
+ // thresholds — detection parameters (when does an event "count"?)
32
+ // scoring — two-tier screenout system:
33
+ // hard: count-based, any one crossing its threshold = screenout
34
+ // soft: weighted accumulation with a separate threshold
35
+ // screenout — master switch + grace period
36
+ export const PRESETS = {
37
+ permissive: {
38
+ // Collect everything, screen out nobody. For calibration/pilot studies.
39
+ signals: {
40
+ paste: true, copy: true, tabAway: true, typingSpeed: true,
41
+ devTools: true, aiExtensions: true, sidebarGap: true,
42
+ keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
43
+ windowPosition: true, clipboardManager: true,
44
+ keystrokeDynamics: false // GDPR: off by default
45
+ },
46
+ thresholds: {
47
+ typingSpeedCps: 15 // more lenient typing speed threshold
48
+ },
49
+ scoring: {
50
+ hard: {
51
+ paste: { countThreshold: 3 },
52
+ drop: { countThreshold: 3 }
53
+ },
54
+ soft: {
55
+ copy: { weight: 1, maxPerTrial: 2 },
56
+ tabAway: { weight: 1, maxPerTrial: 2 },
57
+ typingSpeed: { weight: 1 },
58
+ sidebarEvent: { weight: 1 },
59
+ devTools: { weight: 1 },
60
+ foreignInput: { weight: 1 }
61
+ },
62
+ softScoreThreshold: 10
63
+ },
64
+ screenout: { enabled: false, gracePeriodTrials: 5 }
65
+ },
66
+ standard: {
67
+ // Balanced detection. Default for most studies.
68
+ signals: {
69
+ paste: true, copy: true, tabAway: true, typingSpeed: true,
70
+ devTools: true, aiExtensions: true, sidebarGap: true,
71
+ keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
72
+ windowPosition: true, clipboardManager: true,
73
+ keystrokeDynamics: false // GDPR: off by default
74
+ },
75
+ thresholds: {}, // uses DEFAULT_THRESHOLDS as-is
76
+ scoring: {
77
+ hard: {
78
+ paste: { countThreshold: 2 },
79
+ drop: { countThreshold: 2 }
80
+ },
81
+ soft: {
82
+ copy: { weight: 2, maxPerTrial: 2 },
83
+ tabAway: { weight: 1, maxPerTrial: 2 },
84
+ typingSpeed: { weight: 2 },
85
+ sidebarEvent: { weight: 3 },
86
+ devTools: { weight: 1 },
87
+ foreignInput: { weight: 2 }
88
+ },
89
+ softScoreThreshold: 6
90
+ },
91
+ screenout: { enabled: true, gracePeriodTrials: 3 }
92
+ },
93
+ strict: {
94
+ // Low thresholds, all signals. For high-stakes studies.
95
+ signals: {
96
+ paste: true, copy: true, tabAway: true, typingSpeed: true,
97
+ devTools: true, aiExtensions: true, sidebarGap: true,
98
+ keyboardShortcuts: true, mouseTracking: true, idleGaps: true,
99
+ windowPosition: true, clipboardManager: true,
100
+ keystrokeDynamics: true
101
+ },
102
+ thresholds: {
103
+ typingSpeedCps: 8, // stricter typing speed
104
+ tabAwayDurationMs: 5000, // shorter tab-away counts
105
+ mouseMaxEvents: 5000 // larger cap for detailed analysis
106
+ },
107
+ scoring: {
108
+ hard: {
109
+ paste: { countThreshold: 1 },
110
+ copy: { countThreshold: 2 },
111
+ drop: { countThreshold: 1 }
112
+ },
113
+ soft: {
114
+ tabAway: { weight: 2, maxPerTrial: 1 },
115
+ typingSpeed: { weight: 3 },
116
+ sidebarEvent: { weight: 2 },
117
+ devTools: { weight: 2 },
118
+ foreignInput: { weight: 3 }
119
+ },
120
+ softScoreThreshold: 4
121
+ },
122
+ screenout: { enabled: true, gracePeriodTrials: 2 }
123
+ }
124
+ };
125
+
126
+ // AI extension selectors — used by browser detection to find known AI tools
127
+ // in the DOM. Each entry has a human-readable name and a CSS selector.
128
+ export const AI_SELECTORS = [
129
+ // ChatGPT extensions
130
+ { name: "ChatGPT sidebar", sel: "chatgpt-sidebar" },
131
+ { name: "ChatGPT extension", sel: "[data-chatgpt]" },
132
+ // Copilot browser extension (not Edge's built-in)
133
+ { name: "Copilot extension", sel: "copilot-suggestions" },
134
+ { name: "Copilot extension", sel: "[data-copilot]" },
135
+ // Gemini browser extension (not Chrome's built-in)
136
+ { name: "Gemini extension", sel: "gemini-panel" },
137
+ // Claude browser extension
138
+ { name: "Claude extension", sel: "[data-claude-extension]" },
139
+ // Popular AI extensions
140
+ { name: "Monica AI", sel: "monica-root" },
141
+ { name: "Merlin AI", sel: "merlin-root" },
142
+ { name: "Sider AI", sel: "[data-sider-extension]" },
143
+ { name: "MaxAI", sel: "[data-maxai]" },
144
+ // Generic AI assistant marker
145
+ { name: "AI assistant", sel: "[data-ai-assistant]" },
146
+ // AI iframes (extensions embedding AI services)
147
+ { name: "AI iframe (OpenAI)", sel: 'iframe[src*="chat.openai.com"]' },
148
+ { name: "AI iframe (Copilot)", sel: 'iframe[src*="copilot.microsoft.com"]' },
149
+ { name: "AI iframe (Gemini)", sel: 'iframe[src*="gemini.google.com"]' },
150
+ { name: "AI iframe (Claude)", sel: 'iframe[src*="claude.ai"]' },
151
+ { name: "AI iframe (Perplexity)", sel: 'iframe[src*="perplexity.ai"]' }
152
+ ];
153
+
154
+ // Benign extension tags to exclude from MutationObserver alerts.
155
+ // These are known non-AI extensions that commonly inject DOM elements.
156
+ export const BENIGN_TAGS = [
157
+ "grammarly", "lastpass", "1password", "bitwarden",
158
+ "dashlane", "roboform", "honey", "ublock", "adblock"
159
+ ];
160
+
161
+ // Storage key prefix for localStorage/sessionStorage persistence
162
+ export const STORAGE_PREFIX = "integrity_monitor_";
@@ -0,0 +1,56 @@
1
+ // src/shared/schema.js
2
+ // Defines the expected shape of integrity data for validation.
3
+ // Used by the CLI ingest step and for documentation generation.
4
+
5
+ // The fields that appear in a trial report from endTrial().
6
+ // 'required' means the field must be present for valid integrity data.
7
+ export const TRIAL_REPORT_FIELDS = {
8
+ trialId: { type: 'string', required: true },
9
+ phase: { type: 'string', required: false },
10
+ libraryVersion: { type: 'string', required: true },
11
+ participantId: { type: 'string', required: true },
12
+ startTime: { type: 'number', required: true },
13
+ duration_ms: { type: 'number', required: true },
14
+ pasteEvents: { type: 'array', required: true },
15
+ copyEvents: { type: 'array', required: true },
16
+ dropEvents: { type: 'array', required: true },
17
+ editTimestamps: { type: 'array', required: false },
18
+ charsPerSec: { type: 'number', required: false },
19
+ tabAwayEvents: { type: 'array', required: true },
20
+ idleGaps: { type: 'array', required: false },
21
+ mouseEvents: { type: 'array', required: false },
22
+ mouseMetrics: { type: 'object', required: false },
23
+ extensionsDetected: { type: 'array', required: false },
24
+ sidebarGapPx: { type: 'number', required: false },
25
+ foreignInputEvents: { type: 'array', required: false },
26
+ syntheticInsertions: { type: 'array', required: false },
27
+ decoy: { type: 'object', required: false },
28
+ trialSoftScore: { type: 'number', required: true },
29
+ trialSignals: { type: 'object', required: true }
30
+ };
31
+
32
+ // Default config for the CLI report tool.
33
+ // All fields have sensible defaults — minimal config is just dataDir + filePattern.
34
+ export const DEFAULT_CLI_CONFIG = {
35
+ dataDir: "./data",
36
+ filePattern: "*.json",
37
+ participantIdField: "participantId",
38
+ trialIdField: "trialId",
39
+ trialOrderField: "trialIndex",
40
+ integrityField: "integrity",
41
+ trialsPerParticipant: null,
42
+ platformIdField: null,
43
+ conditionField: null,
44
+ groupField: null,
45
+ typingSpeedThreshold_cps: 10,
46
+ tabAwayMinDuration_ms: 3000,
47
+ idleGapThreshold_ms: 10000,
48
+ suspiciouslyFastRT_ms: 2000,
49
+ scoring: null, // uses library defaults
50
+ signals: null, // all enabled
51
+ trajectoryGrid: "auto",
52
+ trajectoryTrialLabel: "trialId",
53
+ trajectoryResponseField: null,
54
+ outputDir: "./cyborg-hunter-report",
55
+ outputFormat: "html"
56
+ };
@@ -0,0 +1,73 @@
1
+ // src/shared/validation.js
2
+ // Config validation with "did you mean?" suggestions using Levenshtein distance.
3
+ // Used by both library init() and CLI config loading to catch typos early.
4
+
5
+ import { DEFAULT_CLI_CONFIG } from './schema.js';
6
+ import { DEFAULT_THRESHOLDS } from './constants.js';
7
+
8
+ // All recognized config keys across both browser library and CLI.
9
+ // Unknown keys trigger a warning with the closest match suggestion.
10
+ const ALL_KNOWN_KEYS = [
11
+ ...Object.keys(DEFAULT_CLI_CONFIG),
12
+ ...Object.keys(DEFAULT_THRESHOLDS),
13
+ 'preset', 'participantId', 'signals', 'thresholds', 'scoring',
14
+ 'domProtection', 'collectForPostHoc', 'onSignal', 'screenout',
15
+ 'experimentContainer', 'knownInputs', 'decoyAnswers', 'decoyMap',
16
+ 'decoyVisibility', 'decoyFraming', 'decoyExcludeButtons',
17
+ 'autoMonitor', 'excludeTrialTypes'
18
+ ];
19
+
20
+ /**
21
+ * Validates a config object against known keys.
22
+ * Returns an array of warning strings for any unrecognized keys.
23
+ * Each warning includes a "did you mean?" suggestion if a close match exists.
24
+ */
25
+ export function validateConfig(config, knownKeys = ALL_KNOWN_KEYS) {
26
+ const warnings = [];
27
+ Object.keys(config).forEach(key => {
28
+ if (!knownKeys.includes(key)) {
29
+ const suggestion = findClosestKey(key, knownKeys);
30
+ const msg = suggestion
31
+ ? `Unknown config key "${key}" — did you mean "${suggestion}"?`
32
+ : `Unknown config key "${key}"`;
33
+ warnings.push(msg);
34
+ }
35
+ });
36
+ return warnings;
37
+ }
38
+
39
+ /**
40
+ * Finds the closest matching key using Levenshtein distance.
41
+ * Returns null if no key is within edit distance 3 (too different to suggest).
42
+ */
43
+ function findClosestKey(input, keys) {
44
+ let best = null;
45
+ let bestDist = Infinity;
46
+ for (const key of keys) {
47
+ const dist = levenshtein(input.toLowerCase(), key.toLowerCase());
48
+ if (dist < bestDist && dist <= 3) {
49
+ bestDist = dist;
50
+ best = key;
51
+ }
52
+ }
53
+ return best;
54
+ }
55
+
56
+ /**
57
+ * Computes the Levenshtein (edit) distance between two strings.
58
+ * Used for "did you mean?" suggestions on misspelled config keys.
59
+ */
60
+ function levenshtein(a, b) {
61
+ const m = a.length, n = b.length;
62
+ const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
63
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
64
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
65
+ for (let i = 1; i <= m; i++) {
66
+ for (let j = 1; j <= n; j++) {
67
+ dp[i][j] = a[i-1] === b[j-1]
68
+ ? dp[i-1][j-1]
69
+ : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
70
+ }
71
+ }
72
+ return dp[m][n];
73
+ }