cyborg-hunter 0.4.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 +117 -79
- 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 +69 -8
- 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.js → extension-cyborg-hunter.js} +9 -2
- package/src/jspsych/extension-guard-friction.js +1164 -0
- package/src/jspsych/extension-guard-honeypot.js +492 -0
- 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/jspsych-cyborg-hunter.js +0 -1
- package/src/cli/renderers/tab-timeline.js +0 -149
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **cyborg-hunter** are documented here. This project follows
|
|
4
|
+
[Semantic Versioning](https://semver.org).
|
|
5
|
+
|
|
6
|
+
## [0.7.0] — 2026-07-14
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- Session replay recorder (`dist/cyborg-hunter-replay.js`, opt-in): captures pointer,
|
|
10
|
+
keys, clipboard, scroll, touch, viewport, and (at the `dom` tier) DOM snapshots +
|
|
11
|
+
mutations. Wire format is jsPsych's `SessionRecording v1` with a `ch_extensions`
|
|
12
|
+
namespace.
|
|
13
|
+
- CLI report gains a per-participant replay viewer (scrub bar, cursor trail, event
|
|
14
|
+
markers); `dom`-tier recordings reconstruct the page in a sandboxed iframe.
|
|
15
|
+
- Autosave and CLI ingest of replay artifacts, with ownership verification and
|
|
16
|
+
reload-collision handling.
|
|
17
|
+
- Guard-honeypot and guard-friction events are captured in the replay stream.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Replay viewer uses a per-trial camera model for cursor/DOM alignment; legacy
|
|
21
|
+
recordings fall back to a clearly-labeled reduced-alignment mode.
|
|
22
|
+
- Password inputs are redacted unconditionally in replay capture; clipboard events
|
|
23
|
+
record lengths only, not content.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- `drop` listener was incorrectly gated on the `paste` signal flag.
|
|
27
|
+
- Idle-gap and element-trace timers leaked per session instead of being trial-scoped.
|
|
28
|
+
- Edge-exit analysis silently found nothing on modern payloads due to a mismatched
|
|
29
|
+
time base.
|
|
30
|
+
- Shape-3 (top-level array) ingest dropped outer trial fields and skipped tab-away
|
|
31
|
+
normalization.
|
|
32
|
+
- Malformed replay artifacts no longer abort the report; `autoSave()` no longer
|
|
33
|
+
throws on circular/BigInt data.
|
|
34
|
+
|
|
35
|
+
## [0.6.2] — 2026-07-12
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- Cut events now count toward the hard-copy screenout (previously recorded but
|
|
39
|
+
never incremented the session copy count).
|
|
40
|
+
- Misconfigured scoring overrides (e.g. a nested typo) now warn instead of
|
|
41
|
+
silently disabling a rule.
|
|
42
|
+
- Fullscreen detection is prefix-aware, fixing false violations on Safari <16.4
|
|
43
|
+
and some iOS WebViews.
|
|
44
|
+
- Honeypot re-initialization no longer inherits a prior run's violations/state.
|
|
45
|
+
- `decoyAnswer: false` per-trial opt-out is now honored (was coerced to `null`).
|
|
46
|
+
- A payload with both `trials` and `responses` no longer loses its integrity trials.
|
|
47
|
+
- A non-array signal field no longer crashes the report; ingest now coerces and warns.
|
|
48
|
+
- A numeric `trialId`/`ruleId` no longer crashes the trajectory renderer.
|
|
49
|
+
- `findGuardViolations()` now scans all trials instead of locking onto the first.
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- Warnings for an unresolved `participantIdField`, duplicate participant IDs, an
|
|
53
|
+
unmatched `phaseScope` phase name, and a non-numeric `scoring.softScoreThreshold`.
|
|
54
|
+
|
|
55
|
+
## [0.6.1] — 2026-07-06
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
- `endTrial()` stamps a wall-clock ISO `timestamp` on the trial report.
|
|
59
|
+
- Session-level `tabAwayEvents[]` alongside `tabAwaySums`, preserving full timing
|
|
60
|
+
for tab-aways outside `startTrial`/`endTrial`.
|
|
61
|
+
- `participantIdField` accepts dot-paths (e.g. `"metadata.sessionId"`).
|
|
62
|
+
- `sessionIntegrityPath`, `phaseScope`, and `trajectoryDisplayOrder` config options.
|
|
63
|
+
- `showPlatformId` flag (default off) to render a platform ID as a secondary line
|
|
64
|
+
in the HTML report.
|
|
65
|
+
- `--config-file` accepted as an alias of `--config`.
|
|
66
|
+
|
|
67
|
+
### Changed
|
|
68
|
+
- `layoutShifts` renamed to `viewportWidthShifts` (old key kept as a deprecated
|
|
69
|
+
alias); the signal measures viewport-width changes, not Web Vitals CLS.
|
|
70
|
+
- Viewport-shift logging is debounced (250ms default) instead of firing per
|
|
71
|
+
animation frame.
|
|
72
|
+
- Trajectory panels are tinted by phase; `triage.md` carries an explicit Tier column.
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
- `sessionIntegrityPath` no longer accepts a wrong-shaped object at the resolved
|
|
76
|
+
path, which had silently zeroed downstream signals.
|
|
77
|
+
|
|
78
|
+
## [0.6.0] — 2026-06-25
|
|
79
|
+
|
|
80
|
+
No public API was removed. Re-running the report on existing data may shift
|
|
81
|
+
triage scores/ordering (sidebar, tab-away, and hard-flag corrections); newly
|
|
82
|
+
collected data no longer saves raw per-keystroke timings by default.
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
- Sidebar events are counted as distinct openings, not raw log entries.
|
|
86
|
+
- Hard-flag fallback now uses the cumulative session total against the count
|
|
87
|
+
threshold, not per-trial hits.
|
|
88
|
+
- Tab-away binning matches the runtime's strict cutoff.
|
|
89
|
+
- Triage ranks tier-first (hard → soft → clean), then by score.
|
|
90
|
+
- The CLI honors each participant's own saved thresholds instead of generic defaults.
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
- Guard-honeypot self-disclosure surfaced in the report (`honeypot_ai_use`,
|
|
94
|
+
`honeypot_ai_report` columns).
|
|
95
|
+
- `finalize()` persists the runtime config so the CLI can reconstruct each
|
|
96
|
+
participant's screening settings.
|
|
97
|
+
|
|
98
|
+
### Privacy
|
|
99
|
+
- Raw per-keystroke timings are no longer persisted by default
|
|
100
|
+
(`keystrokeDynamics` toggle); only derived typing speed is kept unless opted in.
|
|
101
|
+
|
|
102
|
+
### Fixed
|
|
103
|
+
- Restored the authoritative session score for two saved formats that had been
|
|
104
|
+
dropped, causing over-flagging.
|
|
105
|
+
- Session-timeline offset estimation no longer drifts for legacy participants.
|
|
106
|
+
|
|
107
|
+
## [0.5.1] and earlier
|
|
108
|
+
|
|
109
|
+
See the git tags `v0.4.0`, `v0.5.0`, `v0.5.1` for prior releases (no changelog
|
|
110
|
+
was kept before 0.6.0).
|
package/CITATION.cff
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
title: cyborg-hunter
|
|
3
|
+
message: >-
|
|
4
|
+
If you use this software in academic work, please cite it as below.
|
|
5
|
+
type: software
|
|
6
|
+
authors:
|
|
7
|
+
- family-names: Konuk
|
|
8
|
+
given-names: Can
|
|
9
|
+
- family-names: Btesh
|
|
10
|
+
given-names: Victor
|
|
11
|
+
- family-names: Nunez
|
|
12
|
+
given-names: Jose Luis
|
|
13
|
+
repository-code: https://github.com/konukcan/cyborg-hunter
|
|
14
|
+
abstract: >-
|
|
15
|
+
A JavaScript library and CLI for detecting AI-tool use during browser-based
|
|
16
|
+
behavioral experiments. Captures paste, copy, drag, tab-away, mouse
|
|
17
|
+
trajectories, browser sidebar openings, and other signals that compromise
|
|
18
|
+
data quality on Prolific, MTurk, and classroom studies. Produces a triage
|
|
19
|
+
report ranking participants by suspiciousness.
|
|
20
|
+
keywords:
|
|
21
|
+
- integrity-monitoring
|
|
22
|
+
- cheating-detection
|
|
23
|
+
- online-experiments
|
|
24
|
+
- jspsych
|
|
25
|
+
- prolific
|
|
26
|
+
- behavioral-research
|
|
27
|
+
license: MIT
|
|
28
|
+
version: 0.7.0
|
|
29
|
+
date-released: '2026-07-14'
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Can Konuk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -2,96 +2,134 @@
|
|
|
2
2
|
|
|
3
3
|
Detects AI-tool use during browser-based behavioral experiments. Captures paste, copy, drag, tab-away, mouse trajectories, browser sidebar openings, and other signals that compromise data quality on Prolific / MTurk / classroom studies. Produces a triage report ranking participants by suspiciousness.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Optional companion deterrence modules (`extension-guard-friction.js`, `extension-guard-honeypot.js`) ship in the same package: friction enforces fullscreen + blocks sidebars + scrambles content during violations + asks cooperative LLMs to refuse; honeypot exposes both hidden and visible bait fields that AI agents fill while human participants don't see them.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
### Example: what a report looks like
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|------|-------------|-------|------|--------|
|
|
11
|
-
| 1 | 67c1d6d98c8787be36609212 | 4078 | no | 1 tab-away 3–10s; 2 flickers <3s; fast typing on 9 trials; **1015 synthetic insertions** |
|
|
12
|
-
| 2 | 69bc21dd22f17b2337957511 | 179 | **YES** | 21 paste events; 3 copy events; 9 tab-aways ≥10s; 3 sidebar events; 21 layout shifts |
|
|
13
|
-
| 3 | 69b9f3184e0896ef41b99aa8 | 152 | no | 38 synthetic insertions |
|
|
14
|
-
| 4 | 5d350282cec7150015d16494 | 131 | **YES** | 5 paste events; 10 copy events; 22 tab-aways ≥10s; 5 tab-aways 3–10s; 15 flickers <3s |
|
|
15
|
-
| 5 | 696a9773a84c5f9d4a930874 | 128 | **YES** | 15 paste events; 23 tab-aways ≥10s; 11 tab-aways 3–10s; 9 flickers <3s |
|
|
16
|
-
| 6 | 69b594dccc64df571f092147 | 123 | **YES** | 12 paste events; 16 tab-aways ≥10s; 4 tab-aways 3–10s |
|
|
17
|
-
| 7 | 69cd5149cf6fc07126897938 | 120 | **YES** | 15 paste events; 1 copy events; 19 tab-aways ≥10s; 16 flickers <3s |
|
|
18
|
-
| 8 | 6314bc49055e150c92f4736e | 112 | **YES** | 12 paste events; 14 tab-aways ≥10s; 46 tab-aways 3–10s; 3 flickers <3s |
|
|
9
|
+
The bundled three-participant synthetic dataset (`examples/synthetic-pilot/` — every number hand-authored, no real participant behind any of it) triages like this:
|
|
19
10
|
|
|
20
|
-
|
|
11
|
+
| Rank | Participant | Tier | Score | Reason |
|
|
12
|
+
|------|-------------|------|-------|--------|
|
|
13
|
+
| 1 | SYN-HARD-03 | **HARD** | 18 | 2 paste events; 1 copy events; 3 tab-aways ≥10s; 2 flickers ≤3s; fast typing on 1 trials |
|
|
14
|
+
| 2 | SYN-SOFT-02 | soft | 21 | 3 copy events; 1 tab-away ≥10s; 2 tab-aways 3–10s; 1 sidebar event |
|
|
15
|
+
| 3 | SYN-CLEAN-01 | clean | 0 | 1 flicker ≤3s; 1 layout shifts |
|
|
21
16
|
|
|
22
|
-
|
|
17
|
+
Ranking is **tier-first** (hard-triggered lead, then soft, then clean), score-descending within a tier — rank 1 outranks rank 2 despite the lower score, because hard evidence beats any accumulation of soft evidence. The score is `5×paste + 5×copy + 3×sidebar + 1×tab-away` (counting tab-aways longer than the participant's tab-away threshold — 3s by default, 5s for the strict preset); synthetic insertions and fast typing are surfaced in the reason but do not drive the score. See [docs/cli-reference.md → Triage scoring](docs/cli-reference.md#triage-scoring). The HTML report for the same dataset:
|
|
23
18
|
|
|
24
|
-
|
|
19
|
+

|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
Reproduce the table and page yourself: run `cyborg-hunter report` in `examples/synthetic-pilot/` ([docs/worked-example.md](docs/worked-example.md) interprets every number).
|
|
22
|
+
|
|
23
|
+
## Repo layout
|
|
24
|
+
|
|
25
|
+
- `src/core/` — signal-collection library (the monitor)
|
|
26
|
+
- `src/jspsych/` — jsPsych extension adapters (one per concern)
|
|
27
|
+
- `src/cli/` + `bin/` — CLI that turns saved data into the triage report
|
|
28
|
+
- `tests/`, `docs/` — tests, package docs
|
|
29
|
+
- `examples/synthetic-pilot/` — synthetic three-participant dataset for trying the CLI (see [docs/worked-example.md](docs/worked-example.md))
|
|
28
30
|
|
|
29
31
|
## Install
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
CLI (analysis):
|
|
32
34
|
|
|
33
35
|
```bash
|
|
34
36
|
npm install -g cyborg-hunter
|
|
35
37
|
```
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
Browser (experiment page) — load via unpkg or copy `dist/*.js` into your project:
|
|
38
40
|
|
|
39
41
|
```html
|
|
40
|
-
<!--
|
|
42
|
+
<!-- Signal collection -->
|
|
41
43
|
<script src="https://unpkg.com/cyborg-hunter/dist/cyborg-hunter.min.js"></script>
|
|
42
|
-
<script src="https://unpkg.com/cyborg-hunter/dist/
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<script src="https://unpkg.com/cyborg-hunter
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
<!-- Option 3: copy dist/cyborg-hunter.min.js + dist/jspsych-cyborg-hunter.js
|
|
49
|
-
into your project for fully offline / no-CDN-dependency setups. -->
|
|
44
|
+
<script src="https://unpkg.com/cyborg-hunter/dist/extension-cyborg-hunter.js"></script>
|
|
45
|
+
<!-- Optional: deterrence + bait detection -->
|
|
46
|
+
<script src="https://unpkg.com/cyborg-hunter/dist/extension-guard-friction.js"></script>
|
|
47
|
+
<script src="https://unpkg.com/cyborg-hunter/dist/extension-guard-honeypot.js"></script>
|
|
48
|
+
<!-- Optional: session replay recorder -->
|
|
49
|
+
<script src="https://unpkg.com/cyborg-hunter/dist/cyborg-hunter-replay.js"></script>
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
+
For production studies, pin a version: `https://unpkg.com/cyborg-hunter@0.7.0/dist/...`.
|
|
53
|
+
|
|
52
54
|
## Plug into a jsPsych experiment
|
|
53
55
|
|
|
54
56
|
```javascript
|
|
55
57
|
const jsPsych = initJsPsych({
|
|
56
58
|
extensions: [
|
|
57
|
-
{ type: jsPsychCyborgHunter, params: { participantId:
|
|
59
|
+
{ type: jsPsychCyborgHunter, params: { participantId: participant_id, preset: 'standard' } },
|
|
60
|
+
{ type: jsPsychGuardFriction }, // optional
|
|
61
|
+
{ type: jsPsychGuardHoneypot } // optional
|
|
58
62
|
],
|
|
59
63
|
on_finish: function () {
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
jsPsych.extensions['cyborg-hunter'].finalize();
|
|
64
|
+
jsPsych.extensions['guard-friction'].finalize(); // stop friction first
|
|
65
|
+
jsPsych.extensions['guard-honeypot'].finalize(); // then attach data
|
|
66
|
+
jsPsych.extensions['cyborg-hunter'].finalize(); // then save
|
|
63
67
|
jsPsych.data.get().localSave('csv', 'data.csv');
|
|
64
68
|
}
|
|
65
69
|
});
|
|
66
70
|
|
|
67
|
-
//
|
|
68
|
-
timeline.forEach(t =>
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
71
|
+
timeline.push(jsPsychGuardFriction.entryTrial()); // user-gesture fullscreen entry
|
|
72
|
+
timeline.forEach(t => t.extensions = (t.extensions || []).concat([
|
|
73
|
+
{ type: jsPsychCyborgHunter },
|
|
74
|
+
{ type: jsPsychGuardFriction },
|
|
75
|
+
{ type: jsPsychGuardHoneypot }
|
|
76
|
+
]));
|
|
72
77
|
jsPsych.run(timeline);
|
|
73
78
|
```
|
|
74
79
|
|
|
75
|
-
Full
|
|
80
|
+
`participantId` must exist before `initJsPsych`. Full walk-through (per-trial params, standalone non-jsPsych use, opt-in/exclude modes): [docs/using-cyborg-hunter.md](docs/using-cyborg-hunter.md).
|
|
76
81
|
|
|
77
82
|
## Generate a report
|
|
78
83
|
|
|
79
|
-
After data collection, in the directory holding your data files:
|
|
80
|
-
|
|
81
84
|
```bash
|
|
82
|
-
|
|
85
|
+
cd <your-data-dir>
|
|
86
|
+
cyborg-hunter init # writes cyborg-hunter.config.json
|
|
83
87
|
# edit config: dataDir, filePattern, participantIdField
|
|
84
|
-
cyborg-hunter report
|
|
88
|
+
cyborg-hunter report # writes ./cyborg-hunter-report/
|
|
85
89
|
open cyborg-hunter-report/index.html
|
|
86
90
|
```
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
Output: `summary.csv` (per-participant columns), `triage.md` (ranked list), `event-log.csv` (chronological events), `images/` (per-participant mouse paths, session timelines, typing profiles), `index.html` (landing page).
|
|
93
|
+
|
|
94
|
+
## Session replay
|
|
95
|
+
|
|
96
|
+
The optional replay recorder captures what the participant did and (at the
|
|
97
|
+
`dom` tier) what the page looked like, so a flagged session can be reviewed
|
|
98
|
+
visually instead of adjudicated from counts alone. Recordings use jsPsych
|
|
99
|
+
PR #3661's `SessionRecording v1` wire format with a `ch_extensions` block.
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
// jsPsych: one more extension (declare anywhere; finalize LAST)
|
|
103
|
+
{ type: jsPsychCyborgHunterReplay, params: {
|
|
104
|
+
participantId: participant_id,
|
|
105
|
+
tier: 'dom', // 'trace' (default) | 'dom'
|
|
106
|
+
autoSave: { mode: 'datapipe', experimentId: 'ABC123' } } }
|
|
107
|
+
// on_finish: await jsPsych.extensions['cyborg-hunter-replay'].finalize();
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
// Standalone (any experiment, no jsPsych)
|
|
112
|
+
const rec = CyborgHunterReplay.attach({ participantId, tier: 'dom',
|
|
113
|
+
autoSave: { mode: 'datapipe', experimentId: 'ABC123' } });
|
|
114
|
+
rec.startSession();
|
|
115
|
+
rec.startTrial({ trialId: 'r1' }); // optional bracketing
|
|
116
|
+
rec.endTrial();
|
|
117
|
+
rec.stopSession('finished');
|
|
118
|
+
await rec.autoSaveNow();
|
|
119
|
+
```
|
|
89
120
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
The artifact saves as `<pid>-replay-<epoch>.json` next to your data; the CLI
|
|
122
|
+
picks it up automatically and the report gains a **Session replay** section
|
|
123
|
+
per participant (lazy-loaded scrub viewer — cursor trail, clicks, away
|
|
124
|
+
bands, and a sandboxed reconstruction of the page for `dom`-tier
|
|
125
|
+
recordings). On `dom`-tier recordings the cursor is verified per
|
|
126
|
+
interaction by a five-way alignment self-check — any click it can't
|
|
127
|
+
confirm draws an explicit uncertain marker instead of a wrong one, and
|
|
128
|
+
recordings made before this guarantee existed replay under a reduced-
|
|
129
|
+
guarantees banner. Password fields are always redacted; see
|
|
130
|
+
[docs/using-cyborg-hunter.md](docs/using-cyborg-hunter.md) for the privacy
|
|
131
|
+
model, data-volume guidance, delivery semantics, and the alignment
|
|
132
|
+
guarantee in full.
|
|
95
133
|
|
|
96
134
|
## What it detects
|
|
97
135
|
|
|
@@ -102,56 +140,56 @@ The report contains:
|
|
|
102
140
|
| Copy | Clipboard `copy` events | Soft (weighted) |
|
|
103
141
|
| Tab-away | `visibilitychange` + `blur`/`focus` | Soft (weighted) |
|
|
104
142
|
| Browser sidebar | `innerWidth` delta + layout compression | Soft (weighted) |
|
|
105
|
-
| Suspicious typing speed | chars/sec exceeding
|
|
106
|
-
| Synthetic insertion | text appearing without keystrokes |
|
|
107
|
-
| Foreign input | typing landing outside
|
|
108
|
-
| Idle gaps | input inactivity
|
|
109
|
-
| AI-extension content scripts | DOM scan for known extension selectors |
|
|
110
|
-
| Mouse trajectories | 20Hz polling + path-efficiency
|
|
143
|
+
| Suspicious typing speed | chars/sec exceeding preset threshold | Soft (weighted) |
|
|
144
|
+
| Synthetic insertion | text appearing without keystrokes | Diagnostic (collected, not scored) |
|
|
145
|
+
| Foreign input | typing landing outside experiment container | Soft (weighted) |
|
|
146
|
+
| Idle gaps | input inactivity | Diagnostic (collected, not scored) |
|
|
147
|
+
| AI-extension content scripts | DOM scan for known extension selectors | Diagnostic (collected, not scored) |
|
|
148
|
+
| Mouse trajectories | 20Hz polling + path-efficiency metrics | Diagnostic |
|
|
111
149
|
| Window/screen geometry | polled + resize-event capture, with zoom inference | Diagnostic |
|
|
112
150
|
|
|
113
|
-
Three presets
|
|
151
|
+
Three presets: `permissive` / `standard` (default) / `strict`. Per-signal thresholds: [docs/signals-reference.md](docs/signals-reference.md).
|
|
114
152
|
|
|
115
|
-
|
|
153
|
+
Tab-aways are captured session-wide, not just during trials: since 0.6.1 the session report keeps a timestamped `tabAwayEvents[]` for every tab-away — including those during consent, tutorial, or study phases — so session timelines can place them. Data saved with older versions keeps only durations (`tabAwaySums`) for off-trial events; the timeline footer counts those as unplaceable. (0.6.1 also renames the `layoutShifts` signal to `viewportWidthShifts` — it measures viewport-width changes, not Web-Vitals CLS — keeping the old key as a deprecated alias.)
|
|
116
154
|
|
|
117
|
-
|
|
118
|
-
- **Screen-share / second device.** A participant reading questions on screen 1 and querying ChatGPT on a phone produces no detectable trace from the browser.
|
|
119
|
-
- **AI text edited and retyped.** A determined participant who copies AI output, retypes it character-by-character, and never tab-switches will look clean. The mouse-trajectory and typing-rhythm signals make this much harder than it sounds, but the tool isn't a polygraph.
|
|
155
|
+
The optional **guard** extensions add: fullscreen / sidebar / focus enforcement (with content-scrambling overlay on violation), AI refusal notices in the DOM, and honeypot fields (hidden + visible bait) that catch sidebar-LLMs and agentic browsers (Browser Use, Operator, Computer Use). The visible bait writes `ai_use` / `ai_report` columns into your saved data, and the report surfaces them in `summary.csv` as `honeypot_ai_use` / `honeypot_ai_report` (plus a "self-reported AI use" note in the triage reason).
|
|
120
156
|
|
|
121
|
-
|
|
157
|
+
## What it doesn't detect
|
|
122
158
|
|
|
123
|
-
|
|
159
|
+
- **Native browser AI sidebars** (Chrome Gemini, Edge Copilot) — they leave no extension content scripts. The `innerWidth_delta` heuristic still catches them as generic sidebar events, but the named-extension column stays empty.
|
|
160
|
+
- **Screen-share / second device** — an LLM on a phone reading the screen leaves no in-browser trace.
|
|
161
|
+
- **AI text edited and retyped** — a determined participant who retypes character-by-character without tab-switching looks clean. Mouse-trajectory and typing-rhythm signals raise the bar but it isn't a polygraph.
|
|
124
162
|
|
|
125
|
-
|
|
163
|
+
## Documentation
|
|
126
164
|
|
|
127
|
-
-
|
|
128
|
-
-
|
|
165
|
+
- [docs/quickstart.md](docs/quickstart.md) — zero to triage report
|
|
166
|
+
- [docs/worked-example.md](docs/worked-example.md) — full pipeline run on the bundled synthetic dataset, outputs interpreted
|
|
167
|
+
- [docs/interpreting-signals.md](docs/interpreting-signals.md) — scores vs tiers, viewport shifts, phase scoping: the common misreadings
|
|
168
|
+
- [docs/release-notes-0.7.0.md](docs/release-notes-0.7.0.md) — what 0.7.0 (and 0.6.2) change in data, config, and reports
|
|
169
|
+
- [docs/release-notes-0.6.1.md](docs/release-notes-0.6.1.md) — the 0.6.1 release notes
|
|
170
|
+
- [docs/using-cyborg-hunter.md](docs/using-cyborg-hunter.md) — full integration guide
|
|
171
|
+
- [docs/signals-reference.md](docs/signals-reference.md) — every signal with thresholds per preset
|
|
172
|
+
- [docs/configuration.md](docs/configuration.md) — config file fields and CLI flags
|
|
173
|
+
- [docs/cli-reference.md](docs/cli-reference.md) — commands and output structure
|
|
129
174
|
|
|
130
|
-
|
|
175
|
+
## Development
|
|
131
176
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
- [`docs/using-cyborg-hunter.md`](docs/using-cyborg-hunter.md) — full integration guide (jsPsych and standalone)
|
|
135
|
-
- [`docs/signals-reference.md`](docs/signals-reference.md) — every signal with thresholds per preset
|
|
136
|
-
- [`docs/configuration.md`](docs/configuration.md) — config file fields and CLI flags
|
|
137
|
-
- [`docs/cli-reference.md`](docs/cli-reference.md) — commands and output structure
|
|
138
|
-
- [`docs/design-decisions.md`](docs/design-decisions.md) — why the architecture looks the way it does (two-tier scoring, dual lifecycle, `finalize()`, zoom robustness, etc.)
|
|
177
|
+
Run the test suite with `npm test`. Before publishing, run `scripts/check-public-hygiene.sh` — it fails if any tracked file contains personal or internal-process leakage.
|
|
139
178
|
|
|
140
179
|
## Citation
|
|
141
180
|
|
|
142
|
-
If you use cyborg-hunter in academic work, please cite:
|
|
143
|
-
|
|
144
181
|
```bibtex
|
|
145
|
-
@software{
|
|
146
|
-
author = {Konuk, Can},
|
|
147
|
-
title = {cyborg-hunter:
|
|
182
|
+
@software{konuk_cyborg_hunter,
|
|
183
|
+
author = {Konuk, Can and Btesh, Victor and Nunez, Jose Luis},
|
|
184
|
+
title = {cyborg-hunter: detecting AI-tool use in browser-based behavioral experiments},
|
|
148
185
|
year = {2026},
|
|
186
|
+
version = {0.7.0},
|
|
149
187
|
url = {https://github.com/konukcan/cyborg-hunter},
|
|
150
|
-
|
|
188
|
+
license = {MIT}
|
|
151
189
|
}
|
|
152
190
|
```
|
|
153
191
|
|
|
154
|
-
|
|
192
|
+
A "Cite this repository" button is rendered from `CITATION.cff`.
|
|
155
193
|
|
|
156
194
|
## License
|
|
157
195
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cyborg-hunter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Detect LLM-assisted 'cyborg' behavior in online behavioral experiments",
|
|
5
5
|
"main": "dist/cyborg-hunter.min.js",
|
|
6
6
|
"module": "dist/cyborg-hunter.esm.js",
|
|
@@ -11,14 +11,20 @@
|
|
|
11
11
|
"build": "node build.js",
|
|
12
12
|
"test:browser": "echo 'Open tests/browser/integrity-monitor.test.html in a browser'",
|
|
13
13
|
"test:cli": "node --test tests/cli/*.test.js",
|
|
14
|
+
"test:core": "node --test tests/core/*.test.js",
|
|
14
15
|
"test:jspsych": "node --test tests/jspsych/*.test.js",
|
|
15
|
-
"test": "npm run test:cli && npm run test:jspsych"
|
|
16
|
+
"test": "npm run test:core && npm run test:replay && npm run test:cli && npm run test:jspsych",
|
|
17
|
+
"test:replay": "node --test tests/replay/*.test.js",
|
|
18
|
+
"test:browser:replay": "node tests/browser/replay/run-replay-tests.mjs",
|
|
19
|
+
"test:browser:alignment": "node tests/browser/replay/cursor-alignment.battery.mjs"
|
|
16
20
|
},
|
|
17
21
|
"files": [
|
|
18
22
|
"dist/",
|
|
19
23
|
"bin/",
|
|
20
24
|
"src/",
|
|
21
|
-
"README.md"
|
|
25
|
+
"README.md",
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"CITATION.cff"
|
|
22
28
|
],
|
|
23
29
|
"keywords": [
|
|
24
30
|
"integrity",
|
|
@@ -47,6 +53,7 @@
|
|
|
47
53
|
"canvas": "^3.2.3"
|
|
48
54
|
},
|
|
49
55
|
"devDependencies": {
|
|
56
|
+
"ajv": "^8.20.0",
|
|
50
57
|
"esbuild": "^0.21.0",
|
|
51
58
|
"happy-dom": "^20.9.0"
|
|
52
59
|
},
|
|
@@ -28,7 +28,10 @@ export function detectEdgeExitForTrial(trial, config) {
|
|
|
28
28
|
const events = [];
|
|
29
29
|
|
|
30
30
|
for (const ta of tabAways) {
|
|
31
|
-
|
|
31
|
+
// Modern ingest normalizes tab-away starts to trial-relative startRel_ms;
|
|
32
|
+
// mouse t is already trial-relative, so both must use the same base.
|
|
33
|
+
// Legacy payloads (no startRel_ms) carried trial-relative start directly.
|
|
34
|
+
const taStart = ta.startRel_ms ?? ta.start;
|
|
32
35
|
// Find mouse move events in the TIME_WINDOW before this tab-away
|
|
33
36
|
const beforeTA = mouse.filter(m =>
|
|
34
37
|
m.type === 'move' && m.t < taStart && m.t > taStart - TIME_WINDOW);
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// src/cli/analyzers/phase-scope.js
|
|
2
|
+
// Pre-registration phase scoping.
|
|
3
|
+
//
|
|
4
|
+
// Studies that pre-register integrity scoring over a subset of phases (e.g.
|
|
5
|
+
// counting only classification-phase signals, excluding a warm-up or
|
|
6
|
+
// debrief phase) previously had to enforce that in their own analysis
|
|
7
|
+
// pipeline — the CH triage scored the whole session and was misleading read
|
|
8
|
+
// in isolation. config.phaseScope filters which trials feed the ANALYZERS
|
|
9
|
+
// (summary, edge-exit, triage):
|
|
10
|
+
//
|
|
11
|
+
// "phaseScope": { "include": ["classification"] }
|
|
12
|
+
// "phaseScope": { "exclude": ["warmup", "post_task_query", "debrief"] }
|
|
13
|
+
//
|
|
14
|
+
// `include` (when non-empty) keeps only listed phases; `exclude` then removes
|
|
15
|
+
// its phases. A trial without a `phase` field counts as "default" — data whose
|
|
16
|
+
// trials carry no phase labels should scope with `exclude`, not `include`.
|
|
17
|
+
//
|
|
18
|
+
// What scoping does NOT touch:
|
|
19
|
+
// * Renderers still receive the full participant — the evidence (timelines,
|
|
20
|
+
// trajectories, paste texts) stays visible; only the scores are scoped.
|
|
21
|
+
// * Ambient session-level environment signals with no phase attribution
|
|
22
|
+
// (sidebar events, keyboard shortcuts, viewport-width shifts, zoom
|
|
23
|
+
// changes) remain session-wide — they aren't tied to any one phase, so
|
|
24
|
+
// scoping them wouldn't be meaningful. Response-level signals
|
|
25
|
+
// (paste/copy/drop/tab-away/typing) are the ones that scope.
|
|
26
|
+
//
|
|
27
|
+
// Downstream, computeParticipantSummary sees `phaseScoped: true` and switches
|
|
28
|
+
// to per-trial aggregation: session-level tabAwaySums and the authoritative
|
|
29
|
+
// session soft score cover the WHOLE session, so they cannot be used for a
|
|
30
|
+
// scoped verdict.
|
|
31
|
+
|
|
32
|
+
export function applyPhaseScope(participants, phaseScope) {
|
|
33
|
+
const inc = Array.isArray(phaseScope?.include) && phaseScope.include.length > 0
|
|
34
|
+
? new Set(phaseScope.include) : null;
|
|
35
|
+
const exc = Array.isArray(phaseScope?.exclude) && phaseScope.exclude.length > 0
|
|
36
|
+
? new Set(phaseScope.exclude) : null;
|
|
37
|
+
// No scope configured → hand back the SAME array (byte-identical pipeline).
|
|
38
|
+
if (!inc && !exc) return participants;
|
|
39
|
+
|
|
40
|
+
return participants.map(p => ({
|
|
41
|
+
...p,
|
|
42
|
+
trials: (p.trials || []).filter(t => {
|
|
43
|
+
const phase = (t && t.phase) ?? 'default';
|
|
44
|
+
if (inc && !inc.has(phase)) return false;
|
|
45
|
+
if (exc && exc.has(phase)) return false;
|
|
46
|
+
return true;
|
|
47
|
+
}),
|
|
48
|
+
phaseScoped: true,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Returns the configured phaseScope phase names (from include and exclude)
|
|
53
|
+
// that match NO trial anywhere in the cohort. A non-empty result almost always
|
|
54
|
+
// means a typo (e.g. include:["clasification"]) — which, for an `include`,
|
|
55
|
+
// silently filters EVERY trial from EVERY participant and reports the whole
|
|
56
|
+
// cohort clean. report.js warns on this so the misconfiguration is visible.
|
|
57
|
+
// Exported for testing.
|
|
58
|
+
export function findUnmatchedPhaseScopePhases(participants, phaseScope) {
|
|
59
|
+
const configured = [
|
|
60
|
+
...(Array.isArray(phaseScope?.include) ? phaseScope.include : []),
|
|
61
|
+
...(Array.isArray(phaseScope?.exclude) ? phaseScope.exclude : []),
|
|
62
|
+
];
|
|
63
|
+
if (configured.length === 0) return [];
|
|
64
|
+
const present = new Set();
|
|
65
|
+
for (const p of (participants || [])) {
|
|
66
|
+
for (const t of (p.trials || [])) {
|
|
67
|
+
present.add((t && t.phase) ?? 'default');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return configured.filter(name => !present.has(name));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Human-readable one-liner for the console summary.
|
|
74
|
+
export function describePhaseScope(phaseScope) {
|
|
75
|
+
const parts = [];
|
|
76
|
+
if (Array.isArray(phaseScope?.include) && phaseScope.include.length > 0) {
|
|
77
|
+
parts.push(`include=[${phaseScope.include.join(', ')}]`);
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(phaseScope?.exclude) && phaseScope.exclude.length > 0) {
|
|
80
|
+
parts.push(`exclude=[${phaseScope.exclude.join(', ')}]`);
|
|
81
|
+
}
|
|
82
|
+
return parts.join(' ');
|
|
83
|
+
}
|