@toffee-at/sdk 0.1.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,382 @@
1
+ import type { DetectorResult } from './types';
2
+
3
+ interface MousePoint {
4
+ x: number;
5
+ y: number;
6
+ t: number;
7
+ }
8
+
9
+ /**
10
+ * Persistent behavioral monitor that continuously collects interaction data
11
+ * and can be scored on demand at any time.
12
+ *
13
+ * Key signals for detecting AI agents using browser extensions:
14
+ * - Mouse teleportation (direct jumps to targets, no approach path)
15
+ * - Low trajectory entropy (straight-line or uniform movements)
16
+ * - High movement efficiency (too-direct paths)
17
+ * - No micro-jitter (missing human hand tremor)
18
+ * - Programmatic scrolling (no preceding wheel/touch input)
19
+ */
20
+ export class BehavioralMonitor {
21
+ private mousePoints: MousePoint[] = [];
22
+ private clicks: { x: number; y: number; t: number }[] = [];
23
+ private scrollTimestamps: number[] = [];
24
+ private wheelTimestamps: number[] = [];
25
+ private keyTimings: number[] = [];
26
+ clickDurations: number[] = [];
27
+ allEventTimestamps: number[] = [];
28
+ private pendingMouseDown: number | null = null;
29
+ private lastInputTime = 0;
30
+ private trustedEventCount = 0;
31
+ private untrustedEventCount = 0;
32
+ private active = false;
33
+
34
+ private onMouseMove = (e: MouseEvent) => {
35
+ this.mousePoints.push({ x: e.clientX, y: e.clientY, t: performance.now() });
36
+ if (this.mousePoints.length > 500) this.mousePoints.shift();
37
+ if (e.isTrusted) { this.trustedEventCount++; this.lastInputTime = performance.now(); }
38
+ else { this.untrustedEventCount++; }
39
+ this.allEventTimestamps.push(performance.now());
40
+ };
41
+
42
+ private onClick = (e: MouseEvent) => {
43
+ this.clicks.push({ x: e.clientX, y: e.clientY, t: performance.now() });
44
+ if (e.isTrusted) { this.trustedEventCount++; this.lastInputTime = performance.now(); }
45
+ this.allEventTimestamps.push(performance.now());
46
+ };
47
+
48
+ private onScroll = () => {
49
+ this.scrollTimestamps.push(performance.now());
50
+ this.allEventTimestamps.push(performance.now());
51
+ };
52
+
53
+ private onWheel = () => {
54
+ this.wheelTimestamps.push(performance.now());
55
+ this.lastInputTime = performance.now();
56
+ };
57
+
58
+ private onKeyDown = (e: KeyboardEvent) => {
59
+ this.keyTimings.push(performance.now());
60
+ if (e.isTrusted) { this.trustedEventCount++; this.lastInputTime = performance.now(); }
61
+ this.allEventTimestamps.push(performance.now());
62
+ };
63
+
64
+ onMouseDown(): void {
65
+ this.pendingMouseDown = performance.now();
66
+ this.allEventTimestamps.push(this.pendingMouseDown);
67
+ }
68
+
69
+ onMouseUp(): void {
70
+ const now = performance.now();
71
+ if (this.pendingMouseDown !== null) {
72
+ this.clickDurations.push(now - this.pendingMouseDown);
73
+ this.pendingMouseDown = null;
74
+ }
75
+ this.allEventTimestamps.push(now);
76
+ }
77
+
78
+ getState() {
79
+ return {
80
+ mousePoints: this.mousePoints,
81
+ clicks: this.clicks,
82
+ clickDurations: this.clickDurations,
83
+ scrollTimestamps: this.scrollTimestamps,
84
+ keyTimings: this.keyTimings,
85
+ allEventTimestamps: this.allEventTimestamps,
86
+ };
87
+ }
88
+
89
+ start() {
90
+ if (this.active) return;
91
+ this.active = true;
92
+ document.addEventListener('mousemove', this.onMouseMove, { passive: true });
93
+ document.addEventListener('click', this.onClick, { passive: true });
94
+ document.addEventListener('scroll', this.onScroll, { passive: true });
95
+ document.addEventListener('wheel', this.onWheel, { passive: true });
96
+ document.addEventListener('keydown', this.onKeyDown, { passive: true });
97
+ }
98
+
99
+ stop() {
100
+ if (!this.active) return;
101
+ this.active = false;
102
+ document.removeEventListener('mousemove', this.onMouseMove);
103
+ document.removeEventListener('click', this.onClick);
104
+ document.removeEventListener('scroll', this.onScroll);
105
+ document.removeEventListener('wheel', this.onWheel);
106
+ document.removeEventListener('keydown', this.onKeyDown);
107
+ }
108
+
109
+ /** Score the current behavioral data on demand */
110
+ score(): DetectorResult {
111
+ let score = 0;
112
+ const signals: string[] = [];
113
+
114
+ // === Absence-of-interaction checks ===
115
+ if (this.mousePoints.length === 0) {
116
+ score += 25;
117
+ signals.push('no-mouse-events');
118
+ }
119
+
120
+ if (this.scrollTimestamps.length === 0) {
121
+ score += 10;
122
+ signals.push('no-scroll-events');
123
+ }
124
+
125
+ if (this.keyTimings.length === 0) {
126
+ score += 5;
127
+ signals.push('no-keyboard-events');
128
+ }
129
+
130
+ // All events untrusted (synthetic)
131
+ if (this.untrustedEventCount > 0 && this.trustedEventCount === 0) {
132
+ score += 15;
133
+ signals.push('all-untrusted-events');
134
+ }
135
+
136
+ // === Mouse trajectory analysis ===
137
+ if (this.mousePoints.length >= 3) {
138
+ // 1. Trajectory entropy
139
+ const entropy = computeTrajectoryEntropy(this.mousePoints);
140
+ if (entropy < 0.5) {
141
+ score += 20;
142
+ signals.push(`low-trajectory-entropy:${entropy.toFixed(2)}`);
143
+ } else if (entropy < 1.2) {
144
+ score += 10;
145
+ signals.push(`medium-trajectory-entropy:${entropy.toFixed(2)}`);
146
+ }
147
+
148
+ // 2. Movement efficiency
149
+ const efficiency = computeMovementEfficiency(this.mousePoints);
150
+ if (efficiency > 0.95) {
151
+ score += 15;
152
+ signals.push(`high-movement-efficiency:${efficiency.toFixed(3)}`);
153
+ } else if (efficiency > 0.90) {
154
+ score += 8;
155
+ signals.push(`elevated-movement-efficiency:${efficiency.toFixed(3)}`);
156
+ }
157
+
158
+ // 3. Micro-jitter analysis (humans have involuntary hand tremor)
159
+ // Extension agents generate very few points, so lower the threshold
160
+ if (!hasMicroJitter(this.mousePoints) && this.mousePoints.length >= 3) {
161
+ score += 15;
162
+ signals.push('no-micro-jitter');
163
+ }
164
+
165
+ // 4. Teleportation detection: large jumps with no intermediate points
166
+ // Extension-based agents have multi-second gaps between actions,
167
+ // so we detect by distance + lack of intermediate points, not by timing
168
+ const teleports = countTeleportations(this.mousePoints);
169
+ if (teleports > 0) {
170
+ score += Math.min(teleports * 10, 20);
171
+ signals.push(`mouse-teleportation:${teleports}`);
172
+ }
173
+
174
+ // 5. Very few mouse events relative to interaction time (STRONGEST signal)
175
+ // Humans generate 30-60+ mousemove events per second continuously
176
+ // Extension agents generate exactly 1 event per action (hover/click)
177
+ const timeSpanMs = this.mousePoints[this.mousePoints.length - 1].t - this.mousePoints[0].t;
178
+ if (timeSpanMs > 500) {
179
+ const eventsPerSecond = (this.mousePoints.length / timeSpanMs) * 1000;
180
+ if (eventsPerSecond < 1) {
181
+ // Extremely sparse: < 1 event/sec over a meaningful period
182
+ score += 25;
183
+ signals.push(`very-sparse-mouse-events:${eventsPerSecond.toFixed(2)}/s`);
184
+ } else if (eventsPerSecond < 5) {
185
+ score += 15;
186
+ signals.push(`sparse-mouse-events:${eventsPerSecond.toFixed(1)}/s`);
187
+ }
188
+ }
189
+
190
+ // 6. Point-per-action ratio: compare mouse points to clicks
191
+ // Humans: dozens of mouse events per click (approach, hover, adjust)
192
+ // Agents: 1-2 mouse events per click (teleport to target, click)
193
+ if (this.clicks.length > 0) {
194
+ const pointsPerClick = this.mousePoints.length / this.clicks.length;
195
+ if (pointsPerClick < 3) {
196
+ score += 15;
197
+ signals.push(`low-points-per-click:${pointsPerClick.toFixed(1)}`);
198
+ }
199
+ }
200
+ }
201
+
202
+ // === Pre-click approach analysis ===
203
+ if (this.clicks.length > 0 && this.mousePoints.length > 0) {
204
+ const approach = analyzePreClickApproach(this.mousePoints, this.clicks);
205
+ if (approach.teleportClicks > 0) {
206
+ score += Math.min(approach.teleportClicks * 15, 25);
207
+ signals.push(`teleport-clicks:${approach.teleportClicks}/${this.clicks.length}`);
208
+ }
209
+ if (approach.avgApproachPath < 10 && this.clicks.length >= 1) {
210
+ score += 15;
211
+ signals.push(`minimal-approach-path:${approach.avgApproachPath.toFixed(1)}px`);
212
+ }
213
+ }
214
+
215
+ // === Scroll behavior analysis ===
216
+ if (this.scrollTimestamps.length >= 3) {
217
+ // Programmatic scrolling: scroll events without nearby wheel events
218
+ let programmaticScrolls = 0;
219
+ for (const st of this.scrollTimestamps) {
220
+ const hasNearbyWheel = this.wheelTimestamps.some((wt) => Math.abs(wt - st) < 100);
221
+ if (!hasNearbyWheel) programmaticScrolls++;
222
+ }
223
+ if (programmaticScrolls > this.scrollTimestamps.length * 0.7) {
224
+ score += 10;
225
+ signals.push('programmatic-scrolling');
226
+ }
227
+ }
228
+
229
+ // === Keystroke dynamics ===
230
+ if (this.keyTimings.length >= 5) {
231
+ const intervals: number[] = [];
232
+ for (let i = 1; i < this.keyTimings.length; i++) {
233
+ intervals.push(this.keyTimings[i] - this.keyTimings[i - 1]);
234
+ }
235
+ const mean = intervals.reduce((a, b) => a + b, 0) / intervals.length;
236
+ const variance = intervals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / intervals.length;
237
+ const cv = Math.sqrt(variance) / mean;
238
+ if (cv < 0.15) {
239
+ score += 10;
240
+ signals.push(`uniform-keystroke-timing:cv=${cv.toFixed(3)}`);
241
+ }
242
+ }
243
+
244
+ return {
245
+ detector: 'behavioral',
246
+ rawScore: Math.min(score, 100),
247
+ signals,
248
+ };
249
+ }
250
+ }
251
+
252
+ // Legacy function interface for backwards compatibility
253
+ export function detectBehavioral(): Promise<DetectorResult> {
254
+ return new Promise((resolve) => {
255
+ const monitor = new BehavioralMonitor();
256
+ monitor.start();
257
+ setTimeout(() => {
258
+ monitor.stop();
259
+ resolve(monitor.score());
260
+ }, 3000);
261
+ });
262
+ }
263
+
264
+ // ─── Analysis functions ────────────────────────────────
265
+
266
+ function computeTrajectoryEntropy(points: MousePoint[]): number {
267
+ if (points.length < 3) return 3;
268
+ const NUM_BINS = 8;
269
+ const bins = new Array(NUM_BINS).fill(0);
270
+ for (let i = 1; i < points.length; i++) {
271
+ const dx = points[i].x - points[i - 1].x;
272
+ const dy = points[i].y - points[i - 1].y;
273
+ if (dx === 0 && dy === 0) continue;
274
+ let angle = Math.atan2(dy, dx);
275
+ if (angle < 0) angle += 2 * Math.PI;
276
+ bins[Math.floor((angle / (2 * Math.PI)) * NUM_BINS) % NUM_BINS]++;
277
+ }
278
+ const total = bins.reduce((a, b) => a + b, 0);
279
+ if (total === 0) return 3;
280
+ let entropy = 0;
281
+ for (const count of bins) {
282
+ if (count === 0) continue;
283
+ const p = count / total;
284
+ entropy -= p * Math.log2(p);
285
+ }
286
+ return entropy;
287
+ }
288
+
289
+ function computeMovementEfficiency(points: MousePoint[]): number {
290
+ if (points.length < 2) return 0;
291
+ const segments: MousePoint[][] = [];
292
+ let current: MousePoint[] = [points[0]];
293
+ for (let i = 1; i < points.length; i++) {
294
+ if (points[i].t - points[i - 1].t > 200) {
295
+ if (current.length >= 2) segments.push(current);
296
+ current = [points[i]];
297
+ } else {
298
+ current.push(points[i]);
299
+ }
300
+ }
301
+ if (current.length >= 2) segments.push(current);
302
+ if (segments.length === 0) return 0;
303
+ const efficiencies: number[] = [];
304
+ for (const seg of segments) {
305
+ const start = seg[0], end = seg[seg.length - 1];
306
+ const euclidean = Math.sqrt((end.x - start.x) ** 2 + (end.y - start.y) ** 2);
307
+ let pathLength = 0;
308
+ for (let i = 1; i < seg.length; i++) {
309
+ pathLength += Math.sqrt((seg[i].x - seg[i - 1].x) ** 2 + (seg[i].y - seg[i - 1].y) ** 2);
310
+ }
311
+ if (pathLength > 5) efficiencies.push(euclidean / pathLength);
312
+ }
313
+ if (efficiencies.length === 0) return 0;
314
+ return efficiencies.reduce((a, b) => a + b, 0) / efficiencies.length;
315
+ }
316
+
317
+ function hasMicroJitter(points: MousePoint[]): boolean {
318
+ if (points.length < 10) return true; // Assume human if not enough data
319
+ let reversals = 0, totalSmallMoves = 0;
320
+ for (let i = 2; i < points.length; i++) {
321
+ const dx1 = points[i - 1].x - points[i - 2].x;
322
+ const dy1 = points[i - 1].y - points[i - 2].y;
323
+ const dx2 = points[i].x - points[i - 1].x;
324
+ const dy2 = points[i].y - points[i - 1].y;
325
+ const dist = Math.sqrt(dx2 * dx2 + dy2 * dy2);
326
+ if (dist > 0 && dist < 5) {
327
+ totalSmallMoves++;
328
+ if (dx1 * dx2 < 0 || dy1 * dy2 < 0) reversals++;
329
+ }
330
+ }
331
+ const jitterRatio = totalSmallMoves > 0 ? reversals / totalSmallMoves : 0;
332
+ return jitterRatio > 0.15 || totalSmallMoves > points.length * 0.1;
333
+ }
334
+
335
+ /**
336
+ * Detect teleportation: large jumps between consecutive mouse events.
337
+ *
338
+ * Extension-based agents (Claude-in-Chrome) generate 1 mousemove per action,
339
+ * with large pixel jumps between them. Humans move smoothly with many
340
+ * intermediate events, so consecutive events are close together (< 20px typically).
341
+ *
342
+ * A 200+ pixel jump between consecutive mousemove events is a strong teleport signal.
343
+ */
344
+ function countTeleportations(points: MousePoint[]): number {
345
+ let teleports = 0;
346
+ for (let i = 1; i < points.length; i++) {
347
+ const dx = points[i].x - points[i - 1].x;
348
+ const dy = points[i].y - points[i - 1].y;
349
+ const dist = Math.sqrt(dx * dx + dy * dy);
350
+ // Any large jump between consecutive events is teleportation
351
+ // Humans have smooth paths with many small increments
352
+ if (dist > 200) teleports++;
353
+ }
354
+ return teleports;
355
+ }
356
+
357
+ function analyzePreClickApproach(
358
+ points: MousePoint[],
359
+ clicks: { x: number; y: number; t: number }[]
360
+ ): { teleportClicks: number; avgApproachPath: number } {
361
+ let teleportClicks = 0, totalApproachPath = 0;
362
+ for (const click of clicks) {
363
+ const approachPoints = points.filter((p) => p.t >= click.t - 500 && p.t <= click.t);
364
+ if (approachPoints.length <= 1) {
365
+ teleportClicks++;
366
+ continue;
367
+ }
368
+ let pathLength = 0;
369
+ for (let i = 1; i < approachPoints.length; i++) {
370
+ pathLength += Math.sqrt(
371
+ (approachPoints[i].x - approachPoints[i - 1].x) ** 2 +
372
+ (approachPoints[i].y - approachPoints[i - 1].y) ** 2
373
+ );
374
+ }
375
+ totalApproachPath += pathLength;
376
+ if (pathLength < 10) teleportClicks++;
377
+ }
378
+ return {
379
+ teleportClicks,
380
+ avgApproachPath: clicks.length > 0 ? totalApproachPath / clicks.length : 0,
381
+ };
382
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Extracts the 4 minimal features for the SAINT model classifier
3
+ * from the behavioral monitor's ring buffers.
4
+ *
5
+ * Threshold note: TELEPORT_THRESHOLD_PX is 100px here to match the
6
+ * toffee-model training pipeline (resampler.py). The heuristic detector
7
+ * in behavioral.ts uses 200px for its separate scoring — different purpose.
8
+ */
9
+
10
+ import type { ModelFeatures } from '@toffee-at/shared';
11
+ export type { ModelFeatures };
12
+
13
+ interface MousePoint {
14
+ x: number;
15
+ y: number;
16
+ t: number;
17
+ }
18
+
19
+ interface ClickEvent {
20
+ x: number;
21
+ y: number;
22
+ t: number;
23
+ }
24
+
25
+ const BURST_GAP_MS = 1000;
26
+ const TELEPORT_THRESHOLD_PX = 100;
27
+ const ENTROPY_BINS = 20;
28
+
29
+ function actionBurstRatio(
30
+ clickTimestamps: number[],
31
+ scrollTimestamps: number[],
32
+ keyTimings: number[],
33
+ ): number {
34
+ const allActions = [
35
+ ...clickTimestamps,
36
+ ...scrollTimestamps,
37
+ ...keyTimings,
38
+ ].sort((a, b) => a - b);
39
+
40
+ if (allActions.length < 2) return 0;
41
+
42
+ let burstCount = 0;
43
+ for (let i = 1; i < allActions.length; i++) {
44
+ if (allActions[i] - allActions[i - 1] < BURST_GAP_MS) {
45
+ burstCount++;
46
+ }
47
+ }
48
+ return burstCount / (allActions.length - 1);
49
+ }
50
+
51
+ function clickDurationStd(clickDurations: number[]): number {
52
+ if (clickDurations.length < 2) return 0;
53
+ const mean =
54
+ clickDurations.reduce((a, b) => a + b, 0) / clickDurations.length;
55
+ const variance =
56
+ clickDurations.reduce((sum, d) => sum + (d - mean) ** 2, 0) /
57
+ clickDurations.length;
58
+ return Math.sqrt(variance);
59
+ }
60
+
61
+ function interEventEntropy(allTimestamps: number[]): number {
62
+ if (allTimestamps.length < 2) return 0;
63
+
64
+ const sorted = [...allTimestamps].sort((a, b) => a - b);
65
+ const gaps: number[] = [];
66
+ for (let i = 1; i < sorted.length; i++) {
67
+ gaps.push(sorted[i] - sorted[i - 1]);
68
+ }
69
+
70
+ if (gaps.length === 0) return 0;
71
+
72
+ const maxGap = Math.max(...gaps);
73
+ const minGap = Math.min(...gaps);
74
+ if (maxGap === minGap) return 0;
75
+
76
+ const binWidth = (maxGap - minGap) / ENTROPY_BINS;
77
+ const bins = new Array(ENTROPY_BINS).fill(0);
78
+ for (const gap of gaps) {
79
+ const bin = Math.min(
80
+ Math.floor((gap - minGap) / binWidth),
81
+ ENTROPY_BINS - 1,
82
+ );
83
+ bins[bin]++;
84
+ }
85
+
86
+ let entropy = 0;
87
+ for (const count of bins) {
88
+ if (count > 0) {
89
+ const p = count / gaps.length;
90
+ entropy -= p * Math.log2(p);
91
+ }
92
+ }
93
+ return entropy;
94
+ }
95
+
96
+ function mouseTeleportation(points: MousePoint[]): number {
97
+ let count = 0;
98
+ for (let i = 1; i < points.length; i++) {
99
+ const dx = points[i].x - points[i - 1].x;
100
+ const dy = points[i].y - points[i - 1].y;
101
+ if (Math.sqrt(dx * dx + dy * dy) > TELEPORT_THRESHOLD_PX) {
102
+ count++;
103
+ }
104
+ }
105
+ return count;
106
+ }
107
+
108
+ export function extractModelFeatures(state: {
109
+ mousePoints: MousePoint[];
110
+ clicks: ClickEvent[];
111
+ clickDurations: number[];
112
+ scrollTimestamps: number[];
113
+ keyTimings: number[];
114
+ allEventTimestamps: number[];
115
+ }): ModelFeatures {
116
+ return {
117
+ action_burst_ratio: actionBurstRatio(
118
+ state.clicks.map((c) => c.t),
119
+ state.scrollTimestamps,
120
+ state.keyTimings,
121
+ ),
122
+ click_duration_std: clickDurationStd(state.clickDurations),
123
+ inter_event_entropy: interEventEntropy(state.allEventTimestamps),
124
+ mouse_teleportation: mouseTeleportation(state.mousePoints),
125
+ };
126
+ }
@@ -0,0 +1,115 @@
1
+ import type { DetectorResult } from './types';
2
+
3
+ /**
4
+ * Browser fingerprinting: WebGL renderer + canvas tamper detection.
5
+ * Catches headless browsers using software renderers (SwiftShader, Mesa)
6
+ * and environments that intercept canvas APIs.
7
+ */
8
+ export function detectFingerprint(): DetectorResult {
9
+ let score = 0;
10
+ const signals: string[] = [];
11
+
12
+ // 1. WebGL renderer detection
13
+ try {
14
+ const canvas = document.createElement('canvas');
15
+ const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') as WebGLRenderingContext | null;
16
+ if (gl) {
17
+ const debugInfo = (gl as WebGLRenderingContext).getExtension('WEBGL_debug_renderer_info');
18
+ if (debugInfo) {
19
+ const renderer = (gl as WebGLRenderingContext).getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) as string;
20
+ const vendor = (gl as WebGLRenderingContext).getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) as string;
21
+
22
+ // Software renderers used by headless Chrome
23
+ const softwareRenderers = /swiftshader|mesa offscreen|llvmpipe|softpipe|software rasterizer/i;
24
+ if (softwareRenderers.test(renderer)) {
25
+ score += 35;
26
+ signals.push(`software-renderer:${renderer.toLowerCase().replace(/\s+/g, '-')}`);
27
+ }
28
+
29
+ // Low extension count (real GPUs have 30-50+)
30
+ const extensions = (gl as WebGLRenderingContext).getSupportedExtensions();
31
+ if (extensions && extensions.length < 15) {
32
+ score += 15;
33
+ signals.push('low-webgl-extensions');
34
+ }
35
+
36
+ // Google Inc. as vendor with SwiftShader is definitive headless
37
+ if (/google/i.test(vendor) && /swiftshader/i.test(renderer)) {
38
+ score += 15;
39
+ signals.push('google-swiftshader');
40
+ }
41
+ } else {
42
+ // Missing debug info extension is suspicious
43
+ score += 10;
44
+ signals.push('no-webgl-debug-info');
45
+ }
46
+
47
+ // Check MAX_TEXTURE_SIZE (headless often has low values)
48
+ const maxTexture = (gl as WebGLRenderingContext).getParameter((gl as WebGLRenderingContext).MAX_TEXTURE_SIZE);
49
+ if (maxTexture && maxTexture < 4096) {
50
+ score += 10;
51
+ signals.push('low-max-texture');
52
+ }
53
+ }
54
+ } catch {
55
+ // WebGL not available - handled in headless detector
56
+ }
57
+
58
+ // 2. Canvas tamper detection
59
+ // Real browsers produce deterministic canvas output within a session.
60
+ // If two identical renders produce different results, the canvas API is being intercepted.
61
+ try {
62
+ const canvas1 = document.createElement('canvas');
63
+ canvas1.width = 200;
64
+ canvas1.height = 50;
65
+ const ctx1 = canvas1.getContext('2d');
66
+ if (ctx1) {
67
+ const drawOnCanvas = (ctx: CanvasRenderingContext2D) => {
68
+ ctx.fillStyle = '#f60';
69
+ ctx.fillRect(10, 1, 62, 20);
70
+ ctx.fillStyle = '#069';
71
+ ctx.font = '11pt Arial';
72
+ ctx.fillText('Agent Radar \ud83d\udd0d', 2, 15);
73
+ ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
74
+ ctx.fillRect(50, 25, 40, 15);
75
+ };
76
+
77
+ drawOnCanvas(ctx1);
78
+ const data1 = canvas1.toDataURL();
79
+
80
+ // Draw again on a fresh canvas
81
+ const canvas2 = document.createElement('canvas');
82
+ canvas2.width = 200;
83
+ canvas2.height = 50;
84
+ const ctx2 = canvas2.getContext('2d');
85
+ if (ctx2) {
86
+ drawOnCanvas(ctx2);
87
+ const data2 = canvas2.toDataURL();
88
+
89
+ if (data1 !== data2) {
90
+ // Canvas output is non-deterministic — API is being intercepted
91
+ score += 25;
92
+ signals.push('canvas-tampered');
93
+ }
94
+
95
+ // Check for blank canvas (no rendering subsystem)
96
+ const blankCanvas = document.createElement('canvas');
97
+ blankCanvas.width = 200;
98
+ blankCanvas.height = 50;
99
+ if (data1 === blankCanvas.toDataURL()) {
100
+ score += 20;
101
+ signals.push('canvas-blank');
102
+ }
103
+ }
104
+ }
105
+ } catch {
106
+ score += 10;
107
+ signals.push('canvas-error');
108
+ }
109
+
110
+ return {
111
+ detector: 'fingerprint',
112
+ rawScore: Math.min(score, 100),
113
+ signals,
114
+ };
115
+ }
@@ -0,0 +1,44 @@
1
+ import type { DetectorResult } from './types';
2
+
3
+ export function detectHeadless(): DetectorResult {
4
+ let score = 0;
5
+ const signals: string[] = [];
6
+
7
+ if (navigator.webdriver === true) {
8
+ score += 40;
9
+ signals.push('webdriver-flag');
10
+ }
11
+
12
+ if (navigator.plugins.length === 0) {
13
+ score += 20;
14
+ signals.push('no-plugins');
15
+ }
16
+
17
+ if (!(window as any).chrome && /Chrome/.test(navigator.userAgent)) {
18
+ score += 15;
19
+ signals.push('chrome-without-chrome-object');
20
+ }
21
+
22
+ if (window.outerWidth === 0 || window.outerHeight === 0) {
23
+ score += 15;
24
+ signals.push('zero-dimensions');
25
+ }
26
+
27
+ try {
28
+ const canvas = document.createElement('canvas');
29
+ const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
30
+ if (!gl) {
31
+ score += 10;
32
+ signals.push('no-webgl');
33
+ }
34
+ } catch {
35
+ score += 10;
36
+ signals.push('webgl-error');
37
+ }
38
+
39
+ return {
40
+ detector: 'headless',
41
+ rawScore: Math.min(score, 100),
42
+ signals,
43
+ };
44
+ }