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.
Files changed (58) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +80 -21
  5. package/bin/cyborg-hunter.js +6 -2
  6. package/dist/cyborg-hunter-replay.js +3 -0
  7. package/dist/cyborg-hunter.esm.js +114 -22
  8. package/dist/cyborg-hunter.min.js +3 -3
  9. package/dist/extension-cyborg-hunter.js +1 -1
  10. package/dist/extension-guard-friction.js +5 -5
  11. package/dist/extension-guard-honeypot.js +1 -1
  12. package/package.json +15 -3
  13. package/src/cli/analyzers/edge-exit.js +4 -1
  14. package/src/cli/analyzers/phase-scope.js +83 -0
  15. package/src/cli/analyzers/summary.js +161 -28
  16. package/src/cli/analyzers/triage.js +59 -27
  17. package/src/cli/config.js +26 -1
  18. package/src/cli/extract-core.js +552 -0
  19. package/src/cli/ingest.js +250 -193
  20. package/src/cli/init.js +1 -1
  21. package/src/cli/preview-entry.js +36 -0
  22. package/src/cli/renderers/event-log.js +18 -19
  23. package/src/cli/renderers/extensions.js +12 -3
  24. package/src/cli/renderers/html-index-core.js +1273 -0
  25. package/src/cli/renderers/html-index.js +13 -1047
  26. package/src/cli/renderers/replay-assets.js +67 -0
  27. package/src/cli/renderers/replay-viewer.client.js +1185 -0
  28. package/src/cli/renderers/session-timeline-core.js +907 -0
  29. package/src/cli/renderers/session-timeline.js +33 -0
  30. package/src/cli/renderers/summary-csv.js +5 -0
  31. package/src/cli/renderers/trajectories-core.js +717 -0
  32. package/src/cli/renderers/trajectories.js +31 -635
  33. package/src/cli/renderers/triage-md.js +10 -4
  34. package/src/cli/renderers/typing-profile-core.js +211 -0
  35. package/src/cli/renderers/typing-profile.js +16 -186
  36. package/src/cli/report.js +42 -8
  37. package/src/core/monitor.js +77 -7
  38. package/src/core/scoring.js +11 -2
  39. package/src/core/signals/browser.js +51 -18
  40. package/src/core/signals/clipboard.js +10 -2
  41. package/src/core/signals/dom-protection.js +9 -0
  42. package/src/core/signals/focus.js +16 -2
  43. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  44. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  45. package/src/jspsych/extension-guard-friction.js +43 -14
  46. package/src/jspsych/extension-guard-honeypot.js +25 -1
  47. package/src/replay/capture-dom.js +575 -0
  48. package/src/replay/capture-trace.js +468 -0
  49. package/src/replay/index.js +104 -0
  50. package/src/replay/persistence.js +141 -0
  51. package/src/replay/recorder.js +315 -0
  52. package/src/replay/serializer.js +119 -0
  53. package/src/replay/viewer-model.js +125 -0
  54. package/src/shared/constants.js +12 -6
  55. package/src/shared/paths.js +20 -0
  56. package/src/shared/schema.js +5 -0
  57. package/src/shared/validation.js +55 -0
  58. 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
- // The triage score is a single number combining:
7
- // - Accumulated soft score from the scoring engine
8
- // - Hard signal flags (these dominate with +100)
9
- // - Edge-exit patterns, extension detection, synthetic insertions
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
- softFlagged: (s.authoritativeSoftScore ?? s.totalSoftScore) >= (config.scoring?.softScoreThreshold || 6),
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 descending — most suspicious participants appear first
31
- triageList.sort((a, b) => b.score - a.score);
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
- // IMPORTANT: this function is **strictly behavior-preserving** vs the previous
41
- // inline computeTriageScore. The defensive `|| 0` fallbacks below match the
42
- // original exactly — no extra ones added, no original ones removed. Adding
43
- // new defenses changes how malformed data (e.g. a non-numeric softScore object)
44
- // is rendered into csv/md/png outputs, even when the well-formed-data score
45
- // would be the same. We promised existing users no surprises in this refactor.
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 base = summary.authoritativeSoftScore ?? summary.totalSoftScore;
78
+ const tabAwayMeaningful =
79
+ (summary.tabAwayLongCount || 0) + (summary.tabAwayMediumCount || 0);
50
80
  return [
51
- ['soft', base],
52
- ['hard', hardTriggered ? 100 : 0],
53
- ['edge', edgeExitCount * 3],
54
- ['ext', aiExtensions.length * 5],
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'} 3–10s`);
94
- if (flickN > 0) parts.push(`${flickN} flicker${flickN === 1 ? '' : 's'} <3s`);
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> # alternate config file 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];