cyborg-hunter 0.5.0 → 0.7.2
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 +156 -0
- package/CITATION.cff +29 -0
- package/LICENSE +21 -0
- package/README.md +80 -21
- package/bin/cyborg-hunter.js +6 -2
- package/dist/cyborg-hunter-replay.js +3 -0
- package/dist/cyborg-hunter.esm.js +114 -22
- package/dist/cyborg-hunter.min.js +3 -3
- package/dist/extension-cyborg-hunter.js +1 -1
- package/dist/extension-guard-friction.js +5 -5
- package/dist/extension-guard-honeypot.js +1 -1
- package/package.json +15 -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/extract-core.js +552 -0
- package/src/cli/ingest.js +250 -193
- package/src/cli/init.js +1 -1
- package/src/cli/preview-entry.js +36 -0
- package/src/cli/renderers/event-log.js +18 -19
- package/src/cli/renderers/extensions.js +12 -3
- package/src/cli/renderers/html-index-core.js +1273 -0
- package/src/cli/renderers/html-index.js +13 -1047
- package/src/cli/renderers/replay-assets.js +67 -0
- package/src/cli/renderers/replay-viewer.client.js +1185 -0
- package/src/cli/renderers/session-timeline-core.js +907 -0
- package/src/cli/renderers/session-timeline.js +33 -0
- package/src/cli/renderers/summary-csv.js +5 -0
- package/src/cli/renderers/trajectories-core.js +717 -0
- package/src/cli/renderers/trajectories.js +31 -635
- package/src/cli/renderers/triage-md.js +10 -4
- package/src/cli/renderers/typing-profile-core.js +211 -0
- package/src/cli/renderers/typing-profile.js +16 -186
- package/src/cli/report.js +42 -8
- package/src/core/monitor.js +77 -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 +43 -14
- 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/replay/viewer-model.js +125 -0
- package/src/shared/constants.js +12 -6
- package/src/shared/paths.js +20 -0
- package/src/shared/schema.js +5 -0
- package/src/shared/validation.js +55 -0
- package/src/cli/renderers/tab-timeline.js +0 -149
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
// This is the "start here" document for manual review — the researcher
|
|
4
4
|
// reads the triage list top-to-bottom, inspecting the most suspicious first.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
// -
|
|
8
|
-
// -
|
|
9
|
-
//
|
|
6
|
+
// Ranking has two parts (see rankTriage + decomposeScore below):
|
|
7
|
+
// - tier: hard-triggered → soft-flagged → clean (primary sort key)
|
|
8
|
+
// - score: 5×paste + 5×copy + 3×sidebar-open + 1×tab-away (within a tier),
|
|
9
|
+
// where a "tab-away" is one longer than the participant's tab-away
|
|
10
|
+
// threshold (3s by default, 5s for the strict preset)
|
|
11
|
+
// Other signals (AI extensions, keyboard shortcuts, layout/zoom, edge-exits,
|
|
12
|
+
// synthetic insertions, foreign inputs, honeypot disclosure) are surfaced in the
|
|
13
|
+
// reason and detail panes but do NOT contribute to the score.
|
|
10
14
|
|
|
11
15
|
export function rankTriage(summaries, edgeExits, config) {
|
|
12
16
|
const triageList = summaries.map((s, i) => {
|
|
@@ -21,14 +25,31 @@ export function rankTriage(summaries, edgeExits, config) {
|
|
|
21
25
|
score,
|
|
22
26
|
reason,
|
|
23
27
|
hardTriggered: s.hardTriggered,
|
|
24
|
-
|
|
28
|
+
// Threshold precedence: an explicit analyst-side CLI override
|
|
29
|
+
// (config.scoring.softScoreThreshold) wins for deliberate re-screening;
|
|
30
|
+
// otherwise use the participant's OWN saved threshold (what the library
|
|
31
|
+
// screened them against — e.g. 4 for the strict preset); else default 6.
|
|
32
|
+
// Using a hardcoded 6 here would mislabel strict-preset participants clean.
|
|
33
|
+
softFlagged: (s.authoritativeSoftScore ?? s.totalSoftScore) >=
|
|
34
|
+
(config.scoring?.softScoreThreshold ?? s.softScoreThreshold ?? 6),
|
|
25
35
|
summary: s,
|
|
26
36
|
edgeExitCount: totalEdgeExits
|
|
27
37
|
};
|
|
28
38
|
});
|
|
29
39
|
|
|
30
|
-
// Sort
|
|
31
|
-
|
|
40
|
+
// Sort tier-first (hard, then soft, then clean), and by score descending
|
|
41
|
+
// within a tier. A hard-triggered participant (paste/drop over its count
|
|
42
|
+
// threshold) is categorically more actionable than a high soft-signal count,
|
|
43
|
+
// so they must lead the "start here" triage.md even though the four-term
|
|
44
|
+
// score (paste/copy/sidebar/tab-away) no longer includes a hard-trigger term.
|
|
45
|
+
// This matches the HTML index's "Tier" sort (hard:0, soft:1, clean:2; score
|
|
46
|
+
// desc within tier).
|
|
47
|
+
const tierRank = (t) => (t.hardTriggered ? 0 : t.softFlagged ? 1 : 2);
|
|
48
|
+
triageList.sort((a, b) => {
|
|
49
|
+
const dt = tierRank(a) - tierRank(b);
|
|
50
|
+
if (dt !== 0) return dt;
|
|
51
|
+
return b.score - a.score;
|
|
52
|
+
});
|
|
32
53
|
return triageList;
|
|
33
54
|
}
|
|
34
55
|
|
|
@@ -37,27 +58,30 @@ export function rankTriage(summaries, edgeExits, config) {
|
|
|
37
58
|
// with bars). Keeping the formula here means the score breakdown in the report can
|
|
38
59
|
// never silently disagree with the ranked score it explains.
|
|
39
60
|
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
61
|
+
// Scoring policy (fixed 2026-06-01): only four signals contribute to the
|
|
62
|
+
// score —
|
|
63
|
+
// 5 × paste events
|
|
64
|
+
// 5 × copy events
|
|
65
|
+
// 3 × sidebar events (open cycles)
|
|
66
|
+
// 1 × tab-aways longer than the participant's tab-away threshold (3s default,
|
|
67
|
+
// 5s strict) — excludes at-or-below-cutoff flickers, which are mostly noise
|
|
68
|
+
// from brief URL-bar focus / window edge clicks
|
|
69
|
+
// Hard-trigger, AI extensions, layout shifts, zoom changes, keyboard shortcuts,
|
|
70
|
+
// edge exits, synthetic insertions, and foreign inputs no longer affect the
|
|
71
|
+
// *score*. They are NOT hidden from review: every event list still renders in
|
|
72
|
+
// the per-participant detail panes, and hard-triggered participants are still
|
|
73
|
+
// surfaced via the hard/soft/clean tier (which is independent of this score).
|
|
74
|
+
// edgeExitCount and hardTriggered remain in the signature for the renderer's
|
|
75
|
+
// call site but are unused under this policy.
|
|
46
76
|
export function decomposeScore(summary, edgeExitCount, hardTriggered) {
|
|
47
|
-
const aiExtensions = summary.aiExtensionsFound || summary.extensionsDetected || [];
|
|
48
77
|
const sidebarCount = summary.sidebarEventCount ?? (summary.sidebarDetected ? 1 : 0);
|
|
49
|
-
const
|
|
78
|
+
const tabAwayMeaningful =
|
|
79
|
+
(summary.tabAwayLongCount || 0) + (summary.tabAwayMediumCount || 0);
|
|
50
80
|
return [
|
|
51
|
-
['
|
|
52
|
-
['
|
|
53
|
-
['
|
|
54
|
-
['
|
|
55
|
-
['sidebar', Math.min(sidebarCount * 3, 15)],
|
|
56
|
-
['kb', (summary.keyboardShortcutCount || 0) * 2],
|
|
57
|
-
['layout', (summary.layoutShiftCount || 0)],
|
|
58
|
-
['zoom', (summary.zoomChangeCount || 0)],
|
|
59
|
-
['synth', summary.totalSyntheticInsertions * 4],
|
|
60
|
-
['foreign', summary.totalForeignInputEvents * 3],
|
|
81
|
+
['paste', (summary.totalPasteEvents || 0) * 5],
|
|
82
|
+
['copy', (summary.totalCopyEvents || 0) * 5],
|
|
83
|
+
['sidebar', sidebarCount * 3],
|
|
84
|
+
['tabaway', tabAwayMeaningful],
|
|
61
85
|
];
|
|
62
86
|
}
|
|
63
87
|
|
|
@@ -89,9 +113,14 @@ export function generateTriageReason(summary, edgeExitCount = 0) {
|
|
|
89
113
|
const longN = summary.tabAwayLongCount || 0;
|
|
90
114
|
const midN = summary.tabAwayMediumCount || 0;
|
|
91
115
|
const flickN = summary.tabAwayFlickerCount || 0;
|
|
116
|
+
// Bin boundaries follow THIS participant's tab-away cutoff (3s by default, 5s
|
|
117
|
+
// for strict) so the reason matches the counts/score, which are now computed
|
|
118
|
+
// against that same per-participant cutoff. Flicker is at-or-below the cutoff
|
|
119
|
+
// (not scored); medium is above the cutoff and under 10s.
|
|
120
|
+
const cutoffS = Math.round((summary.tabAwayCutoffMs ?? 3000) / 1000);
|
|
92
121
|
if (longN > 0) parts.push(`${longN} tab-away${longN === 1 ? '' : 's'} ≥10s`);
|
|
93
|
-
if (midN > 0) parts.push(`${midN} tab-away${midN === 1 ? '' : 's'}
|
|
94
|
-
if (flickN > 0) parts.push(`${flickN} flicker${flickN === 1 ? '' : 's'}
|
|
122
|
+
if (midN > 0) parts.push(`${midN} tab-away${midN === 1 ? '' : 's'} ${cutoffS}–10s`);
|
|
123
|
+
if (flickN > 0) parts.push(`${flickN} flicker${flickN === 1 ? '' : 's'} ≤${cutoffS}s`);
|
|
95
124
|
} else if (summary.totalTabAways > 0) {
|
|
96
125
|
parts.push(`${summary.totalTabAways} tab-aways`);
|
|
97
126
|
}
|
|
@@ -111,6 +140,9 @@ export function generateTriageReason(summary, edgeExitCount = 0) {
|
|
|
111
140
|
if (edgeExitCount > 0) parts.push(`${edgeExitCount} edge-exit patterns`);
|
|
112
141
|
if (summary.totalSyntheticInsertions > 0) parts.push(`${summary.totalSyntheticInsertions} synthetic insertions`);
|
|
113
142
|
if (summary.totalForeignInputEvents > 0) parts.push(`${summary.totalForeignInputEvents} foreign inputs`);
|
|
143
|
+
// Guard-honeypot self-disclosure — a strong corroborating signal, surfaced in
|
|
144
|
+
// the reason though it does not feed the four-term score.
|
|
145
|
+
if (summary.honeypotAiUse) parts.push('self-reported AI use (honeypot)');
|
|
114
146
|
if (parts.length === 0) parts.push('clean');
|
|
115
147
|
return parts.join('; ');
|
|
116
148
|
}
|
package/src/cli/config.js
CHANGED
|
@@ -35,6 +35,7 @@ export function loadConfig(cliArgs) {
|
|
|
35
35
|
if (flags.participantIdField) config.participantIdField = flags.participantIdField;
|
|
36
36
|
if (flags.filePattern) config.filePattern = flags.filePattern;
|
|
37
37
|
if (flags.integrityField) config.integrityField = flags.integrityField;
|
|
38
|
+
if (flags.sessionIntegrityPath) config.sessionIntegrityPath = flags.sessionIntegrityPath;
|
|
38
39
|
if (flags['no-visuals']) config.noVisuals = true;
|
|
39
40
|
|
|
40
41
|
// Resolve relative paths to absolute (relative to cwd)
|
|
@@ -45,20 +46,42 @@ export function loadConfig(cliArgs) {
|
|
|
45
46
|
const warnings = validateConfig(fileConfig);
|
|
46
47
|
warnings.forEach(w => console.warn(`[cyborg-hunter] ${w}`));
|
|
47
48
|
|
|
49
|
+
// Validate config VALUES that would otherwise silently mis-score (a
|
|
50
|
+
// non-numeric softScoreThreshold coerces every `score >= threshold` to
|
|
51
|
+
// false, disabling soft flagging with no error).
|
|
52
|
+
cliConfigWarnings(config).forEach(w => console.warn(`[cyborg-hunter] ${w}`));
|
|
53
|
+
|
|
48
54
|
return config;
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
// Value-level CLI config checks that catch misconfigurations which would
|
|
58
|
+
// otherwise silently zero out a signal or verdict. Returns warning strings.
|
|
59
|
+
// Exported for testing.
|
|
60
|
+
export function cliConfigWarnings(config) {
|
|
61
|
+
const warnings = [];
|
|
62
|
+
const thr = config?.scoring?.softScoreThreshold;
|
|
63
|
+
if (thr != null && typeof thr !== 'number') {
|
|
64
|
+
warnings.push(
|
|
65
|
+
`scoring.softScoreThreshold is not a number (got ${JSON.stringify(thr)}) — ` +
|
|
66
|
+
`every soft-score comparison coerces to false, so NO participant will be ` +
|
|
67
|
+
`soft-flagged. Set it to a number.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return warnings;
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
// Parses CLI arguments into a flags object.
|
|
52
74
|
//
|
|
53
75
|
// Supported flags (kebab-case aliases match the camelCase config keys, so
|
|
54
76
|
// you can use whichever form you remember):
|
|
55
|
-
// --config <path>
|
|
77
|
+
// --config, --config-file <path> # alternate config file path
|
|
56
78
|
// --data, --data-dir <path> # config.dataDir
|
|
57
79
|
// --output, --output-dir <path> # config.outputDir
|
|
58
80
|
// --participant <id> # filter to single participant
|
|
59
81
|
// --participant-id-field <name> # config.participantIdField
|
|
60
82
|
// --file-pattern <glob> # config.filePattern
|
|
61
83
|
// --integrity-field <name> # config.integrityField
|
|
84
|
+
// --session-integrity-path <p> # config.sessionIntegrityPath (dotted)
|
|
62
85
|
// --no-visuals # skip canvas-rendered images
|
|
63
86
|
//
|
|
64
87
|
// Throws on unknown flags. Earlier behavior was to print a warning and keep
|
|
@@ -70,6 +93,7 @@ export function parseFlags(args) {
|
|
|
70
93
|
// form so loadConfig can look it up directly.
|
|
71
94
|
const SINGLE_VALUE = {
|
|
72
95
|
'--config': 'config',
|
|
96
|
+
'--config-file': 'config',
|
|
73
97
|
'--data': 'data',
|
|
74
98
|
'--data-dir': 'data',
|
|
75
99
|
'--output': 'output',
|
|
@@ -78,6 +102,7 @@ export function parseFlags(args) {
|
|
|
78
102
|
'--participant-id-field': 'participantIdField',
|
|
79
103
|
'--file-pattern': 'filePattern',
|
|
80
104
|
'--integrity-field': 'integrityField',
|
|
105
|
+
'--session-integrity-path': 'sessionIntegrityPath',
|
|
81
106
|
};
|
|
82
107
|
for (let i = 0; i < args.length; i++) {
|
|
83
108
|
const a = args[i];
|