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.
- package/README.md +145 -0
- package/bin/cyborg-hunter.js +20 -0
- package/dist/cyborg-hunter.esm.js +1534 -0
- package/dist/cyborg-hunter.min.js +6 -0
- package/dist/jspsych-cyborg-hunter.js +1 -0
- package/package.json +56 -0
- package/src/cli/analyzers/edge-exit.js +56 -0
- package/src/cli/analyzers/summary.js +99 -0
- package/src/cli/analyzers/triage.js +104 -0
- package/src/cli/config.js +99 -0
- package/src/cli/ingest.js +343 -0
- package/src/cli/init.js +31 -0
- package/src/cli/renderers/event-log.js +66 -0
- package/src/cli/renderers/extensions.js +43 -0
- package/src/cli/renderers/html-index.js +329 -0
- package/src/cli/renderers/summary-csv.js +70 -0
- package/src/cli/renderers/tab-timeline.js +149 -0
- package/src/cli/renderers/trajectories.js +607 -0
- package/src/cli/renderers/triage-md.js +29 -0
- package/src/cli/renderers/typing-profile.js +200 -0
- package/src/cli/report.js +100 -0
- package/src/core/index.js +13 -0
- package/src/core/monitor.js +404 -0
- package/src/core/scoring.js +153 -0
- package/src/core/signals/browser.js +303 -0
- package/src/core/signals/clipboard.js +91 -0
- package/src/core/signals/dom-protection.js +285 -0
- package/src/core/signals/focus.js +101 -0
- package/src/core/signals/mouse.js +117 -0
- package/src/core/signals/typing.js +110 -0
- package/src/core/state-machine.js +88 -0
- package/src/jspsych/extension.js +199 -0
- package/src/shared/constants.js +162 -0
- package/src/shared/schema.js +56 -0
- package/src/shared/validation.js +73 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Cyborg Hunter v0.3.0 — https://github.com/konukcan/cyborg-hunter
|
|
2
|
+
var CyborgHunter=(()=>{var V=Object.defineProperty;var fe=Object.getOwnPropertyDescriptor;var he=Object.getOwnPropertyNames;var ge=Object.prototype.hasOwnProperty;var ve=(e,n)=>{for(var t in n)V(e,t,{get:n[t],enumerable:!0})},me=(e,n,t,o)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of he(n))!ge.call(e,r)&&r!==t&&V(e,r,{get:()=>n[r],enumerable:!(o=fe(n,r))||o.enumerable});return e};var ye=e=>me(V({},"__esModule",{value:!0}),e);var Me={};ve(Me,{VERSION:()=>N,addHoneypot:()=>de,init:()=>pe,preventTextSelection:()=>ce,setAltText:()=>ue});var B={created:["session","destroyed"],session:["trial","destroyed"],trial:["session","destroyed"],destroyed:[]};function z(){let e="created";return{get current(){return e},transition(n){return B[e]&&B[e].indexOf(n)!==-1?(e=n,!0):(console.warn("[CyborgHunter] Invalid transition: "+e+" \u2192 "+n),!1)}}}function F(e){if(e===null||typeof e!="object")return e;try{return JSON.parse(JSON.stringify(e))}catch{return e}}var N="0.3.0",H={pasteMinChars:0,dropMinChars:0,sidebarGapPx:100,layoutCompressionPx:20,syntheticGapMs:100,idleGapMs:1e4,idleCheckIntervalMs:5e3,mouseThrottleMs:50,mouseMaxEvents:2e3,mouseBotMinEvents:3,sidebarPollMs:2e3,extensionScanMs:3e4,windowPositionPollMs:2e3,elementTraceHz:500,tabAwayDurationMs:3e3,typingSpeedCps:10},U={permissive:{signals:{paste:!0,copy:!0,tabAway:!0,typingSpeed:!0,devTools:!0,aiExtensions:!0,sidebarGap:!0,keyboardShortcuts:!0,mouseTracking:!0,idleGaps:!0,windowPosition:!0,clipboardManager:!0,keystrokeDynamics:!1},thresholds:{typingSpeedCps:15},scoring:{hard:{paste:{countThreshold:3},drop:{countThreshold:3}},soft:{copy:{weight:1,maxPerTrial:2},tabAway:{weight:1,maxPerTrial:2},typingSpeed:{weight:1},sidebarEvent:{weight:1},devTools:{weight:1},foreignInput:{weight:1}},softScoreThreshold:10},screenout:{enabled:!1,gracePeriodTrials:5}},standard:{signals:{paste:!0,copy:!0,tabAway:!0,typingSpeed:!0,devTools:!0,aiExtensions:!0,sidebarGap:!0,keyboardShortcuts:!0,mouseTracking:!0,idleGaps:!0,windowPosition:!0,clipboardManager:!0,keystrokeDynamics:!1},thresholds:{},scoring:{hard:{paste:{countThreshold:2},drop:{countThreshold:2}},soft:{copy:{weight:2,maxPerTrial:2},tabAway:{weight:1,maxPerTrial:2},typingSpeed:{weight:2},sidebarEvent:{weight:3},devTools:{weight:1},foreignInput:{weight:2}},softScoreThreshold:6},screenout:{enabled:!0,gracePeriodTrials:3}},strict:{signals:{paste:!0,copy:!0,tabAway:!0,typingSpeed:!0,devTools:!0,aiExtensions:!0,sidebarGap:!0,keyboardShortcuts:!0,mouseTracking:!0,idleGaps:!0,windowPosition:!0,clipboardManager:!0,keystrokeDynamics:!0},thresholds:{typingSpeedCps:8,tabAwayDurationMs:5e3,mouseMaxEvents:5e3},scoring:{hard:{paste:{countThreshold:1},copy:{countThreshold:2},drop:{countThreshold:1}},soft:{tabAway:{weight:2,maxPerTrial:1},typingSpeed:{weight:3},sidebarEvent:{weight:2},devTools:{weight:2},foreignInput:{weight:3}},softScoreThreshold:4},screenout:{enabled:!0,gracePeriodTrials:2}}},W=[{name:"ChatGPT sidebar",sel:"chatgpt-sidebar"},{name:"ChatGPT extension",sel:"[data-chatgpt]"},{name:"Copilot extension",sel:"copilot-suggestions"},{name:"Copilot extension",sel:"[data-copilot]"},{name:"Gemini extension",sel:"gemini-panel"},{name:"Claude extension",sel:"[data-claude-extension]"},{name:"Monica AI",sel:"monica-root"},{name:"Merlin AI",sel:"merlin-root"},{name:"Sider AI",sel:"[data-sider-extension]"},{name:"MaxAI",sel:"[data-maxai]"},{name:"AI assistant",sel:"[data-ai-assistant]"},{name:"AI iframe (OpenAI)",sel:'iframe[src*="chat.openai.com"]'},{name:"AI iframe (Copilot)",sel:'iframe[src*="copilot.microsoft.com"]'},{name:"AI iframe (Gemini)",sel:'iframe[src*="gemini.google.com"]'},{name:"AI iframe (Claude)",sel:'iframe[src*="claude.ai"]'},{name:"AI iframe (Perplexity)",sel:'iframe[src*="perplexity.ai"]'}],J=["grammarly","lastpass","1password","bitwarden","dashlane","roboform","honey","ublock","adblock"];var X={dataDir:"./data",filePattern:"*.json",participantIdField:"participantId",trialIdField:"trialId",trialOrderField:"trialIndex",integrityField:"integrity",trialsPerParticipant:null,platformIdField:null,conditionField:null,groupField:null,typingSpeedThreshold_cps:10,tabAwayMinDuration_ms:3e3,idleGapThreshold_ms:1e4,suspiciouslyFastRT_ms:2e3,scoring:null,signals:null,trajectoryGrid:"auto",trajectoryTrialLabel:"trialId",trajectoryResponseField:null,outputDir:"./cyborg-hunter-report",outputFormat:"html"};var we=[...Object.keys(X),...Object.keys(H),"preset","participantId","signals","thresholds","scoring","domProtection","collectForPostHoc","onSignal","screenout","experimentContainer","knownInputs","decoyAnswers","decoyMap","decoyVisibility","decoyFraming","decoyExcludeButtons","autoMonitor","excludeTrialTypes"];function Y(e,n=we){let t=[];return Object.keys(e).forEach(o=>{if(!n.includes(o)){let r=Se(o,n),s=r?`Unknown config key "${o}" \u2014 did you mean "${r}"?`:`Unknown config key "${o}"`;t.push(s)}}),t}function Se(e,n){let t=null,o=1/0;for(let r of n){let s=Te(e.toLowerCase(),r.toLowerCase());s<o&&s<=3&&(o=s,t=r)}return t}function Te(e,n){let t=e.length,o=n.length,r=Array.from({length:t+1},()=>Array(o+1).fill(0));for(let s=0;s<=t;s++)r[s][0]=s;for(let s=0;s<=o;s++)r[0][s]=s;for(let s=1;s<=t;s++)for(let a=1;a<=o;a++)r[s][a]=e[s-1]===n[a-1]?r[s-1][a-1]:1+Math.min(r[s-1][a],r[s][a-1],r[s-1][a-1]);return r[t][o]}function $(e,n,t,o){var r=t.scoring,s={hard:{},soft:{}},a=0;if(r.hard.paste){var c=(e.pasteEvents||[]).length;s.hard.paste={trialHits:c,sessionTotal:n.pasteCount,countThreshold:r.hard.paste.countThreshold}}if(r.hard.copy){var g=(e.copyEvents||[]).length;s.hard.copy={trialHits:g,sessionTotal:n.copyCount,countThreshold:r.hard.copy.countThreshold}}if(r.hard.drop){var p=(e.dropEvents||[]).length;s.hard.drop={trialHits:p,sessionTotal:n.dropCount,countThreshold:r.hard.drop.countThreshold}}if(r.soft.copy){var h=(e.copyEvents||[]).length,y=r.soft.copy.maxPerTrial!=null?Math.min(h,r.soft.copy.maxPerTrial):h,T=y*r.soft.copy.weight;a+=T,s.soft.copy={hits:h,capped:y,score:T}}if(r.soft.tabAway){var w=(e.tabAwayEvents||[]).filter(function(i){return(i.duration_ms||0)>t.thresholds.tabAwayDurationMs}).length,x=r.soft.tabAway.maxPerTrial!=null?Math.min(w,r.soft.tabAway.maxPerTrial):w,I=x*r.soft.tabAway.weight;a+=I,s.soft.tabAway={hits:w,capped:x,score:I}}if(r.soft.typingSpeed&&o.charsPerSec!=null){var b=t.thresholds.typingSpeedCps,u=o.charsPerSec>b?1:0,l=u*r.soft.typingSpeed.weight;a+=l,s.soft.typingSpeed={charsPerSec:o.charsPerSec,threshold:b,hit:u,score:l}}if(r.soft.sidebarEvent){var f=n.sidebarEvents.filter(function(i){return i.t>=e.startTime&&i.t<=performance.now()}).length;if(f>0){var d=r.soft.sidebarEvent.weight;a+=d,s.soft.sidebarEvent={hits:f,score:d}}}if(r.soft.devTools){var v=n.keyboardShortcuts.filter(function(i){return i.t>=e.startTime&&i.t<=performance.now()}).length;if(v>0){var E=r.soft.devTools.weight;a+=E,s.soft.devTools={hits:v,score:E}}}if(r.soft.foreignInput&&e.foreignInputEvents.length>0){var M=e.foreignInputEvents.length,C=r.soft.foreignInput.weight;a+=C,s.soft.foreignInput={hits:M,score:C}}for(var k={},P=Object.keys(r.hard),q=0;q<P.length;q++){var L=P[q],O=L==="paste"?n.pasteCount:L==="copy"?n.copyCount:L==="drop"?n.dropCount:0;k[L]={count:O,threshold:r.hard[L].countThreshold,triggered:O>=r.hard[L].countThreshold}}return{trialSoftScore:a,trialSignals:s,updatedHardScore:k}}function Q(e,n){if(!n.screenout.enabled)return!1;var t=Object.keys(e.hardScore).some(function(o){return e.hardScore[o].triggered});return t?!0:e.trialsCompleted<n.screenout.gracePeriodTrials?!1:e.softScore>=n.scoring.softScoreThreshold}function Z(e){var n=e.config,t=e.trialData;n.signals.paste&&e.addTrialListener(document,"paste",function(o){var r="";try{r=(o.clipboardData||window.clipboardData).getData("text")}catch{}var s=r.length;if(s>=n.thresholds.pasteMinChars){var a=e.isKnownInput(o.target),c={type:"paste",t:performance.now(),pastedLength:s,isKnownInput:a};n.collectForPostHoc.pasteDropContent&&(c.text=r),t.pasteEvents.push(c),e.sessionData.pasteCount++,e.fireSignal("paste",o,{pastedLength:s,isKnownInput:a,text:r})}}),n.signals.copy&&(e.addTrialListener(document,"copy",function(o){var r=window.getSelection(),s=r?r.toString():"",a=s.length;t.copyEvents.push({type:"copy",t:performance.now(),selectedLength:a}),e.sessionData.copyCount++,e.fireSignal("copy",o,{selectedLength:a})}),e.addTrialListener(document,"cut",function(o){t.copyEvents.push({type:"cut",t:performance.now()}),e.fireSignal("cut",o,{})})),n.signals.paste&&e.addTrialListener(document,"drop",function(o){var r="";try{r=o.dataTransfer.getData("text")}catch{}var s=r.length;if(s>=n.thresholds.dropMinChars){var a=e.isKnownInput(o.target),c={type:"drop",t:performance.now(),droppedLength:s,isKnownInput:a};n.collectForPostHoc.pasteDropContent&&(c.text=r),t.dropEvents.push(c),e.sessionData.dropCount++,e.fireSignal("drop",o,{droppedLength:s,isKnownInput:a,text:r})}})}function ee(e){var n=e.config;if(n.signals.tabAway){let r=function(a){t===null&&(t=performance.now(),o=a,e.fireSignal("tabAway",null,{type:a}))},s=function(){if(t!==null){var a=performance.now()-t,c={start:t,duration_ms:Math.round(a),type:o};e.getTrialData()&&e.getTrialData().tabAwayEvents.push(c),e.sessionData.tabAwaySums.push(Math.round(a)),e.fireSignal("tabReturn",null,{duration_ms:Math.round(a),type:o}),t=null,o=null}};var t=null,o=null;e.addListener(document,"visibilitychange",function(){document.hidden?r("tabHidden"):s()}),e.addListener(window,"blur",function(){r("windowBlur")}),e.addListener(window,"focus",function(){document.hidden||s()})}}function te(e){var n=e.config,t=e.trialData;if(n.signals.idleGaps){var o=n.thresholds.idleGapMs,r=performance.now();e.addTrialListener(document,"keydown",function(){r=performance.now()}),e.addTrialListener(document,"mousemove",function(){r=performance.now()},{passive:!0});var s=setInterval(function(){if(e.getTrialData()){var a=performance.now()-r;a>o&&(t.idleGaps.push({duration_ms:Math.round(a),t:performance.now()}),r=performance.now())}},n.thresholds.idleCheckIntervalMs);e.addInterval(s)}}function re(e){var n=e.config,t=e.trialData;if(n.signals.mouseTracking){let c=function(g){return function(p){t.mouseEvents.length>=r||t.mouseEvents.push({x:Math.round(p.pageX),y:Math.round(p.pageY),t:Math.round(performance.now()-a),type:g})}};var o=n.thresholds.mouseThrottleMs,r=n.thresholds.mouseMaxEvents,s=0,a=t.startTime;t.mouseTrackingCapped=!1,t.mouseTrackingCappedAtMs=null,e.addTrialListener(document,"mousemove",function(g){if(t.mouseEvents.length>=r){t.mouseTrackingCapped||(t.mouseTrackingCapped=!0,t.mouseTrackingCappedAtMs=Math.round(performance.now()-a));return}var p=performance.now();p-s<o||(s=p,t.mouseEvents.push({x:Math.round(g.pageX),y:Math.round(g.pageY),t:Math.round(p-a),type:"move"}))},{passive:!0}),e.addTrialListener(document,"click",c("click"),{passive:!0}),e.addTrialListener(document,"mousedown",c("down"),{passive:!0}),e.addTrialListener(document,"mouseup",c("up"),{passive:!0})}}function ne(e,n){var t=e.filter(function(M){return M.type==="move"});if(t.length<n)return null;for(var o=0,r=[],s=0,a=0,c=0,g=0,p=1;p<t.length;p++){var h=t[p].x-t[p-1].x,y=t[p].y-t[p-1].y,T=Math.sqrt(h*h+y*y);o+=T;var w=t[p].t-t[p-1].t;w>0&&r.push(T/w),p>1&&((h>0&&c<0||h<0&&c>0)&&s++,(y>0&&g<0||y<0&&g>0)&&a++),c=h,g=y}for(var x=t[0],I=t[t.length-1],b=Math.sqrt(Math.pow(I.x-x.x,2)+Math.pow(I.y-x.y,2)),u=o>0?Math.round(b/o*1e3)/1e3:0,l=0,f=0,d=0;d<r.length;d++){var v=r[d]-l;l+=v/(d+1),f+=v*(r[d]-l)}var E=r.length>1?Math.round(f/(r.length-1)*1e4)/1e4:0;return{pathEfficiency:u,directionChanges:s+a,speedVariance:E,moveCount:t.length}}function oe(e){var n=e.config,t=e.trialData;if(n.signals.clipboardManager){var o=0;e.addTrialListener(document,"keydown",function(){o=performance.now()}),e.addTrialListener(document,"input",function(r){if(r.target&&(r.target.tagName==="INPUT"||r.target.tagName==="TEXTAREA"||r.target.isContentEditable)&&(t.editTimestamps.push(performance.now()),performance.now()-o>n.thresholds.syntheticGapMs&&r.inputType==="insertText")){var s=(r.data||"").length;t.syntheticInsertions.push({type:"synthetic_insertion",t:performance.now(),dataLength:s}),e.fireSignal("syntheticInsertion",r,{dataLength:s})}},!0)}n.signals.typingSpeed&&!n.signals.clipboardManager&&e.addTrialListener(document,"input",function(r){r.target&&(r.target.tagName==="INPUT"||r.target.tagName==="TEXTAREA"||r.target.isContentEditable)&&t.editTimestamps.push(performance.now())},!0)}function ae(e){var n=e.config,t=e.trialData;e.addTrialListener(document,"input",function(o){var r=o.target;if(r){var s=r.tagName==="INPUT"||r.tagName==="TEXTAREA"||r.isContentEditable;if(s){var a=e.isKnownInput(r);if(a===!1){var c={t:performance.now(),targetTag:r.tagName||"unknown",targetId:(r.id||"").slice(0,50),targetClass:(r.className||"").toString().slice(0,100),inputType:o.inputType||""};n.collectForPostHoc.foreignInputContent&&(c.data=o.data||""),t.foreignInputEvents.push(c),e.fireSignal("typingOutsideExperiment",o,{targetTag:c.targetTag,targetId:c.targetId,targetClass:c.targetClass,data:o.data||""})}}}},!0)}function ie(e){if(e.length<2)return null;var n=e[0],t=e[e.length-1],o=(t-n)/1e3;if(o<=0)return null;var r=e.length;return Math.round(r/o*10)/10}function se(e){var n=e.config,t=e.sessionData;if(n.signals.sidebarGap||n.signals.devTools){var o=window.devicePixelRatio||1,r=window.innerWidth,s=!1,a=null,c=null,g=0,p=null,h=!1,y=null;e.addInterval(setInterval(function(){var l=window.innerWidth,f=n.thresholds.sidebarGapPx,d=r-l;if(!s&&d>f)s=!0,a=performance.now(),c="innerWidth_delta",g=d,p=l,t.sidebarEvents.push({type:"opened",method:"innerWidth_delta",deltaIW:d,innerWidth:l,baselineIW:r,t:performance.now()}),e.fireSignal("sidebarOpened",null,{deltaIW:d});else if(s&&c==="innerWidth_delta"&&d<f/2){var v=performance.now()-a,E=p!=null?p-l:null;t.sidebarEvents.push({type:"closed",method:"innerWidth_delta",deltaIW:E,duration_ms:Math.round(v),t:performance.now()}),e.fireSignal("sidebarClosed",null,{duration_ms:Math.round(v),deltaIW:E}),s=!1,a=null,p=null,r=l}else s||(r=l);var M=l-document.documentElement.clientWidth,C=n.thresholds.layoutCompressionPx;if(!h&&M>C)h=!0,y=performance.now(),t.sidebarEvents.push({type:"opened",method:"layout_compression",gap:M,t:performance.now()});else if(h&&M<=C){var k=performance.now()-y;t.sidebarEvents.push({type:"closed",method:"layout_compression",duration_ms:Math.round(k),t:performance.now()}),h=!1,y=null}var P=window.devicePixelRatio||1;P!==o&&(t.zoomChanges.push({from:o,to:P,t:performance.now()}),r=l,o=P)},n.thresholds.sidebarPollMs))}if(n.signals.aiExtensions){let l=function(){W.forEach(function(f){if(document.querySelector(f.sel)){var d=t.aiExtensionsFound.some(function(v){return v.name===f.name});d||t.aiExtensionsFound.push({name:f.name,t:performance.now()})}})};l(),e.addInterval(setInterval(l,n.thresholds.extensionScanMs))}if(n.signals.keyboardShortcuts&&e.addListener(document,"keydown",function(l){var f=l.ctrlKey||l.metaKey;if(f&&l.shiftKey&&(l.key==="I"||l.key==="J"||l.key==="C")){var d="Ctrl+Shift+"+l.key;t.keyboardShortcuts.push({combo:d,t:performance.now()}),e.fireSignal("keyboardShortcut",l,{combo:d})}l.key==="F12"&&(t.keyboardShortcuts.push({combo:"F12",t:performance.now()}),e.fireSignal("keyboardShortcut",l,{combo:"F12"}))}),n.signals.windowPosition&&n.collectForPostHoc.windowPositionLog){var T=function(){t.windowPositions.push({x:window.screenX,y:window.screenY,w:window.outerWidth,h:window.outerHeight,iw:window.innerWidth,ih:window.innerHeight,sw:window.screen.width,sh:window.screen.height,dpr:window.devicePixelRatio,vvScale:window.visualViewport?window.visualViewport.scale:null,t:performance.now()})};e.addInterval(setInterval(T,n.thresholds.windowPositionPollMs));var w=null,x=function(){w&&clearTimeout(w),w=setTimeout(function(){T(),w=null},250)};e.addListener(window,"resize",x)}if(n.signals.aiExtensions){let l=function(f){var d=f.toLowerCase();return J.some(function(v){return d.indexOf(v)!==-1})};var I=new MutationObserver(function(f){for(var d=0;d<f.length;d++)for(var v=f[d].addedNodes,E=0;E<v.length;E++){var M=v[E];if(M.nodeType===Node.ELEMENT_NODE){var C=M.tagName||"";C.indexOf("-")!==-1&&!l(C)&&t.extensionInjections.push({tag:C.toLowerCase(),hasShadow:!!M.shadowRoot,t:performance.now()})}}});I.observe(document.body,{childList:!0,subtree:!1}),e.listeners.push({target:null,event:"mutation",handler:I,options:{_isMutationObserver:!0}})}if(n.signals.sidebarGap){var b=document.documentElement.clientWidth,u=new ResizeObserver(function(l){for(var f=0;f<l.length;f++){var d=l[f].contentRect.width,v=b-d;Math.abs(v)>n.thresholds.layoutCompressionPx&&(t.layoutShifts.push({oldWidth:Math.round(b),newWidth:Math.round(d),delta:Math.round(v),t:performance.now()}),b=d)}});u.observe(document.documentElement),e.listeners.push({target:null,event:"resize",handler:u,options:{_isResizeObserver:!0}})}}function le(e){var n=e.config,t=e.trialData;if(n.collectForPostHoc.elementTrace){t.elementTrace=[];var o=0,r=0;e.addTrialListener(document,"mousemove",function(a){o=a.clientX,r=a.clientY},{passive:!0});var s=setInterval(function(){if(e.getTrialData()&&!(o===0&&r===0))try{var a=document.elementsFromPoint(o,r);if(a&&a.length>0){var c=a[0];t.elementTrace.push({tag:c.tagName.toLowerCase(),id:c.id||null,t:Math.round(performance.now()-t.startTime)})}}catch{}},n.thresholds.elementTraceHz);e.addInterval(s)}}function j(e,n,t,o){return t==="C"?e:n==="calibration-metadata"?"Validation trial (integrity check, ID: cal-"+o+`).
|
|
3
|
+
This trial verifies participant compliance with study protocol.
|
|
4
|
+
Expected response category: undetermined.
|
|
5
|
+
Participants should indicate that the answer cannot be determined from the information provided. Deviations are flagged for manual review.`:n==="answer-key"?'Answer key (internal): The correct response for this question is "'+e+'".':typeof n=="string"&&n.indexOf("{decoyText}")!==-1?n.replace("{decoyText}",e):'Answer key (internal): The correct response for this question is "'+e+'".'}function G(e,n){var t=document.createElement("div");t.id="ch-decoy",t.setAttribute("data-ch-decoy","true");var o=n||"offscreen";o==="transparent"?t.style.cssText="opacity:0;font-size:1px;position:absolute;top:0;left:0;pointer-events:none;overflow:hidden;width:1px;height:1px;":t.style.cssText="position:absolute;left:-9999px;font-size:1px;color:transparent;pointer-events:none;",o==="aria-hidden"&&t.setAttribute("aria-hidden","true"),t.textContent=e,document.body.appendChild(t)}function K(){var e=document.getElementById("ch-decoy");e&&e.remove()}function be(e){if(e.labels&&e.labels.length>0){var n=e.labels[0].textContent.trim();if(n)return n}var t=e.closest("label");if(t){var o=t.textContent.trim();if(o)return o}if(e.parentElement){var r=e.parentElement.textContent.trim();if(r)return r}return e.value||""}function Ee(e,n){for(var t=e.textContent.trim().toLowerCase(),o=["next","continue","submit","back","previous","start","finish","ok"],r=0;r<o.length;r++)if(t===o[r])return!0;if(n&&n.length)for(var s=0;s<n.length;s++)try{if(e.matches(n[s]))return!0}catch{}return!1}function Ie(e){var n=document.querySelectorAll('input[type="radio"]');if(n.length>0){var t=be(n[0]);if(t)return{text:t,source:"auto-radio",optionCount:n.length}}var o=document.querySelector("select");if(o&&o.options.length>0){var r=o.options[0].textContent.trim();if(r)return{text:r,source:"auto-select",optionCount:o.options.length}}for(var s=document.querySelectorAll("button"),a=0;a<s.length;a++)if(!Ee(s[a],e)){var c=s[a].textContent.trim();if(c)return{text:c,source:"auto-button",optionCount:s.length}}return null}var D=0;function R(e,n,t,o,r){if(t.decoyAnswers){var s=r();if(!(s&&s.trialId!==n)){D++;var a=null;if(typeof e.decoyAnswer=="string"&&e.decoyAnswer.length>0){var c=j(e.decoyAnswer,t.decoyFraming,null,D);G(c,t.decoyVisibility),a={level:1,injectedText:e.decoyAnswer,source:"per-trial-override",framing:t.decoyFraming}}else if(t.decoyMap&&t.decoyMap[n]){var g=t.decoyMap[n],p=typeof g=="string"?g:g.text,h=typeof g=="object"&&g.type?g.type:"unknown",y=j(p,t.decoyFraming,h,D);G(y,t.decoyVisibility),a={level:2,injectedText:p,type:h,source:"decoyMap",framing:t.decoyFraming}}else if(t.decoyMap){console.warn('IntegrityMonitor: No decoyMap entry for trialId "'+n+'". Falling back to Level 3/4.');var T=j(null,"calibration-metadata",null,D);G(T,t.decoyVisibility),a={level:4,injectedText:T,source:"calibration-fallback",framing:"calibration-metadata"}}else try{var w=Ie(t.decoyExcludeButtons);if(w){var x=j(w.text,t.decoyFraming,null,D);G(x,t.decoyVisibility),a={level:3,injectedText:w.text,source:w.source,optionCount:w.optionCount,framing:t.decoyFraming}}else{var I=j(null,"calibration-metadata",null,D);G(I,t.decoyVisibility),a={level:4,injectedText:I,source:"calibration-fallback",framing:"calibration-metadata"}}}catch{var b=j(null,"calibration-metadata",null,D);G(b,t.decoyVisibility),a={level:4,injectedText:b,source:"calibration-error",framing:"calibration-metadata"}}var u=r();u&&a&&(u.decoy=a)}}}function ce(e){for(var n=document.querySelectorAll(e),t=0;t<n.length;t++)n[t].style.userSelect="none",n[t].style.webkitUserSelect="none",n[t].style.msUserSelect="none"}function de(e,n){for(var t=document.querySelectorAll(e),o=0;o<t.length;o++){var r=document.createElement("span");r.textContent=n,r.setAttribute("data-honeypot","true"),r.style.cssText="position:absolute;left:-9999px;font-size:0;opacity:0;pointer-events:none;";var s=window.getComputedStyle(t[o]).position;s==="static"&&(t[o].style.position="relative"),t[o].appendChild(r)}}function ue(e,n){for(var t=document.querySelectorAll(e),o=0;o<t.length;o++)t[o].setAttribute("alt",n)}var _=null;function pe(e){_&&(_.destroy(),_=null);var n=e.preset||"standard",t=U[n];if(!t)throw new Error("IntegrityMonitor: unknown preset '"+n+"'. Use: permissive, standard, strict.");var o=e.scoring||{},r=t.scoring,s={hard:Object.assign({},r.hard,o.hard||{}),soft:Object.assign({},r.soft,o.soft||{}),softScoreThreshold:o.softScoreThreshold!=null?o.softScoreThreshold:r.softScoreThreshold},a={participantId:e.participantId||"unknown",preset:n,signals:Object.assign({},t.signals,e.signals||{}),thresholds:Object.assign({},H,t.thresholds||{},e.thresholds||{}),scoring:s,screenout:Object.assign({},t.screenout,e.screenout||{}),domProtection:Object.assign({preventTextSelection:!1,honeypotText:null,logCopyAttempts:!0,renderStimulusAsCanvas:!1},e.domProtection||{}),collectForPostHoc:Object.assign({fullKeystrokeTimestamps:!1,rawMouseTrack:!1,responseText:!1,windowPositionLog:!0,elementTrace:!1,pasteDropContent:!0,foreignInputContent:!0},e.collectForPostHoc||{}),decoyAnswers:e.decoyAnswers||!1,decoyMap:e.decoyMap||null,decoyVisibility:e.decoyVisibility||"offscreen",decoyFraming:e.decoyFraming||"answer-key",decoyExcludeButtons:e.decoyExcludeButtons||[".jspsych-btn"],_debug:e._debug||!1},c=["participantId","preset","signals","thresholds","scoring","screenout","domProtection","collectForPostHoc","decoyAnswers","decoyMap","decoyVisibility","decoyFraming","decoyExcludeButtons","onSignal","experimentContainer","knownInputs","_debug"],g=Y(e,c);g.forEach(function(i){console.warn("IntegrityMonitor: "+i)}),Object.freeze(a),Object.freeze(a.signals),Object.freeze(a.thresholds),Object.freeze(a.scoring),Object.freeze(a.scoring.hard),Object.freeze(a.scoring.soft),Object.freeze(a.screenout);var p=typeof e.onSignal=="function"?e.onSignal:null,h=e.experimentContainer||null,y=null,T=e.knownInputs||null;function w(){return y||(h?(typeof h=="string"?y=document.querySelector(h):h.nodeType&&(y=h),y):null)}function x(i){if(!i)return!1;if(T)try{if(i.matches(T))return!0}catch{}var m=w();return m?m.contains(i):null}function I(i,m,S){if(p)try{p({type:i,event:m||null,trialId:l?l.trialId:null,data:S||{}})}catch{}}var b=z(),u={pasteCount:0,copyCount:0,dropCount:0,tabAwaySums:[],charsPerSec:[],sidebarEvents:[],devToolsEvents:[],aiExtensionsFound:[],keyboardShortcuts:[],windowPositions:[],idleGaps:[],extensionInjections:[],layoutShifts:[],zoomChanges:[],hardScore:{},softScore:0,trialsCompleted:0},l=null,f=[],d=[],v=[];function E(i){if(!b.transition(i))throw new Error("IntegrityMonitor: invalid transition "+b.current+" \u2192 "+i)}function M(i,m,S,A){i.addEventListener(m,S,A||!1),f.push({target:i,event:m,handler:S,options:A||!1})}function C(i,m,S,A){i.addEventListener(m,S,A||!1),d.push({target:i,event:m,handler:S,options:A||!1})}function k(i){v.push(i)}function P(){d.forEach(function(i){i.target.removeEventListener(i.event,i.handler,i.options)}),d=[],K()}function q(){return{config:a,sessionData:u,listeners:f,addListener:M,addInterval:k,fireSignal:I,getTrialData:function(){return l}}}function L(){return{config:a,sessionData:u,trialData:l,listeners:f,addTrialListener:C,addInterval:k,fireSignal:I,isKnownInput:x,getTrialData:function(){return l}}}var O={startSession:function(){E("session");var i=q();se(i),ee(i)},startTrial:function(i){if(E("trial"),i=i||{},i.experimentContainer!=null&&(h=i.experimentContainer,y=null),i.knownInputs!=null&&(T=i.knownInputs),l={trialId:i.trialId||"trial-"+u.trialsCompleted,phase:i.phase||"default",startTime:performance.now(),pasteEvents:[],copyEvents:[],dropEvents:[],editTimestamps:[],mouseEvents:[],tabAwayEvents:[],idleGaps:[],foreignInputEvents:[],syntheticInsertions:[]},K(),a.decoyAnswers){var m=l.trialId;i.decoyAnswer===!1?l.decoy={level:0,source:"skipped"}:typeof i.decoyAnswer=="string"&&i.decoyAnswer.length>0?R(i,m,a,l,function(){return l}):a.decoyMap&&a.decoyMap[m]?R(i,m,a,l,function(){return l}):i.decoyAnswer!=null&&i.decoyAnswer!==!1?(console.warn("IntegrityMonitor: decoyAnswer must be a non-empty string, got: "+typeof i.decoyAnswer+". Falling through to auto-detection."),setTimeout(function(){R(i,m,a,l,function(){return l})},0)):setTimeout(function(){R(i,m,a,l,function(){return l})},0)}else i.decoyAnswer!=null&&console.warn("IntegrityMonitor: decoyAnswer provided but decoyAnswers is not enabled. Add decoyAnswers: true to init() config.");var S=L();Z(S),oe(S),re(S),te(S),le(S),(h||T)&&ae(S)},endTrial:function(){if(E("session"),!l)return null;l.decoy&&(l.decoy.survivedTrial=!!document.getElementById("ch-decoy")),P();var i=F(l);i.duration_ms=performance.now()-l.startTime,i.libraryVersion=N,i.participantId=a.participantId;var m=ie(l.editTimestamps);m!==null&&(i.charsPerSec=m,u.charsPerSec.push(m));var S=ne(l.mouseEvents,a.thresholds.mouseBotMinEvents);S&&(i.mouseMetrics=S),u.trialsCompleted++;var A=$(l,u,a,i);return i.trialSoftScore=A.trialSoftScore,i.trialSignals=A.trialSignals,u.softScore+=A.trialSoftScore,u.hardScore=A.updatedHardScore,l=null,i},getSessionScore:function(){return{hardScore:F(u.hardScore),softScore:u.softScore,softScoreThreshold:a.scoring.softScoreThreshold,anyHardTriggered:Object.keys(u.hardScore).some(function(i){return u.hardScore[i].triggered}),trialsCompleted:u.trialsCompleted}},getSessionReport:function(){var i=F(u);return i.hardScore=F(u.hardScore),i.softScore=u.softScore,i.softScoreThreshold=a.scoring.softScoreThreshold,i.anyHardTriggered=Object.keys(u.hardScore).some(function(m){return u.hardScore[m].triggered}),i.trialsCompleted=u.trialsCompleted,i.config={preset:a.preset,participantId:a.participantId},i.libraryVersion=N,i},shouldScreenout:function(){return Q(u,a)},getRawData:function(){return F({session:u,config:{preset:a.preset,participantId:a.participantId},libraryVersion:N})},getTrialSnapshot:function(){return l?F(l):null},destroy:function(){b.transition("destroyed"),f.forEach(function(i){i.options&&i.options._isMutationObserver||i.options&&i.options._isResizeObserver?i.handler.disconnect():i.target.removeEventListener(i.event,i.handler,i.options)}),f=[],P(),v.forEach(function(i){clearInterval(i)}),v=[],_=null}};return Object.freeze(O),_=O,O}return ye(Me);})();
|
|
6
|
+
if(typeof window!=="undefined")window.IntegrityMonitor=CyborgHunter;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{var n=class{static info={name:"cyborg-hunter",version:"0.1.0",data:{integrity:{type:"object"}}};constructor(t){this.jsPsych=t,this.monitor=null,this.params={},this._monitoring=!1,this._trialStart_perfNow=null}initialize(t){this.params=t;let{autoMonitor:r,excludeTrialTypes:e,...i}=t,o=window.CyborgHunter||window.IntegrityMonitor;if(!o)throw new Error("CyborgHunter not found. Load cyborg-hunter.min.js before the jsPsych extension.");this.monitor=o.init(i),this.monitor.startSession()}on_start(t){}on_load(t){if(this._monitoring=!1,this.params.autoMonitor===!1&&(!t||!t.trialId))return;if(this.params.excludeTrialTypes){let i=this.jsPsych.getCurrentTrial().type?.info?.name||"";if(this.params.excludeTrialTypes.includes(i))return}this._monitoring=!0,this._trialStart_perfNow=performance.now();let r=this.jsPsych.getProgress().current_trial_global;this.monitor.startTrial({trialId:t&&t.trialId||`trial-${r}`,phase:t?.phase||null,decoyAnswer:t?.decoyAnswer||null,experimentContainer:t?.experimentContainer||null})}on_finish(t){if(!this._monitoring)return{};this._monitoring=!1;let r=this.monitor.endTrial();return r.trialStart_perfNow=this._trialStart_perfNow,this._trialStart_perfNow=null,{integrity:r}}finalize(){if(this.monitor){try{let t=this.monitor.getSessionReport(),{sidebarEvents:r=[],keyboardShortcuts:e=[],windowPositions:i=[],layoutShifts:o=[],zoomChanges:y=[],idleGaps:d=[],extensionInjections:u=[],tabAwaySums:g=[],charsPerSec:f=[],aiExtensionsFound:p=[],hardScore:m,softScore:s,anyHardTriggered:a,trialsCompleted:w,softScoreThreshold:_,pasteCount:l=0,copyCount:h=0,dropCount:c=0,libraryVersion:S,config:b,...C}=t;this.jsPsych.data.addProperties({integrityPasteCount:l,integrityCopyCount:h,integrityDropCount:c,integrityAnyHardTriggered:!!a,integritySoftScore:s,cyborgHunterVersion:S}),this.jsPsych.data.addDataToLastTrial({integritySession:{pasteCount:l,copyCount:h,dropCount:c,sidebarEvents:r,keyboardShortcuts:e,windowPositions:i,layoutShifts:o,zoomChanges:y,idleGaps:d,extensionInjections:u,tabAwaySums:g,charsPerSec:f,aiExtensionsFound:p,...C},integrityScore:{hardScore:m,softScore:s,anyHardTriggered:a,trialsCompleted:w,softScoreThreshold:_}})}catch(t){console.warn("[cyborg-hunter] Failed to save session report:",t)}this.monitor.destroy()}}};typeof window<"u"&&(window.jsPsychCyborgHunter=n);})();
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cyborg-hunter",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Detect LLM-assisted 'cyborg' behavior in online behavioral experiments",
|
|
5
|
+
"main": "dist/cyborg-hunter.min.js",
|
|
6
|
+
"module": "dist/cyborg-hunter.esm.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cyborg-hunter": "bin/cyborg-hunter.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "node build.js",
|
|
12
|
+
"test:browser": "echo 'Open tests/browser/integrity-monitor.test.html in a browser'",
|
|
13
|
+
"test:cli": "node --test tests/cli/*.test.js",
|
|
14
|
+
"test:jspsych": "node --test tests/jspsych/*.test.js",
|
|
15
|
+
"test": "npm run test:cli && npm run test:jspsych"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist/",
|
|
19
|
+
"bin/",
|
|
20
|
+
"src/",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"integrity",
|
|
25
|
+
"cheating-detection",
|
|
26
|
+
"online-experiments",
|
|
27
|
+
"jspsych",
|
|
28
|
+
"prolific",
|
|
29
|
+
"mturk",
|
|
30
|
+
"behavioral-research"
|
|
31
|
+
],
|
|
32
|
+
"type": "module",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"author": "Can Konuk",
|
|
35
|
+
"homepage": "https://github.com/konukcan/cyborg-hunter#readme",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/konukcan/cyborg-hunter.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/konukcan/cyborg-hunter/issues"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18.0.0"
|
|
45
|
+
},
|
|
46
|
+
"optionalDependencies": {
|
|
47
|
+
"canvas": "^3.2.3"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"esbuild": "^0.21.0",
|
|
51
|
+
"happy-dom": "^20.9.0"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"papaparse": "^5.5.3"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/cli/analyzers/edge-exit.js
|
|
2
|
+
// Detects the "edge-exit" mouse pattern: mouse moves to window edge just before
|
|
3
|
+
// a tab-away event, then returns from the same edge region after the tab.
|
|
4
|
+
//
|
|
5
|
+
// This pattern suggests the participant switched to a side-by-side lookup window
|
|
6
|
+
// (ChatGPT, notes, etc.) rather than accidentally clicking away. The algorithm:
|
|
7
|
+
// 1. For each tab-away event, find mouse events in the 2s before it
|
|
8
|
+
// 2. Check if the last mouse position was near a screen edge (within 50px)
|
|
9
|
+
// 3. Record which edge (left/right) and the coordinates
|
|
10
|
+
|
|
11
|
+
const EDGE_MARGIN = 50; // pixels from window edge to count as "near edge"
|
|
12
|
+
const TIME_WINDOW = 2000; // ms — how far back to look for edge proximity
|
|
13
|
+
|
|
14
|
+
export function detectEdgeExits(participants, config) {
|
|
15
|
+
return participants.map(p => ({
|
|
16
|
+
participantId: p.participantId,
|
|
17
|
+
edgeExits: p.trials.map(t => detectEdgeExitForTrial(t, config))
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function detectEdgeExitForTrial(trial, config) {
|
|
22
|
+
const mouse = trial.mouseEvents || [];
|
|
23
|
+
const tabAways = trial.tabAwayEvents || [];
|
|
24
|
+
// Use reported window width, or fall back to config, or assume 1920
|
|
25
|
+
const width = config.width || trial.windowWidth || 1920;
|
|
26
|
+
|
|
27
|
+
let edgeExitCount = 0;
|
|
28
|
+
const events = [];
|
|
29
|
+
|
|
30
|
+
for (const ta of tabAways) {
|
|
31
|
+
const taStart = ta.start;
|
|
32
|
+
// Find mouse move events in the TIME_WINDOW before this tab-away
|
|
33
|
+
const beforeTA = mouse.filter(m =>
|
|
34
|
+
m.type === 'move' && m.t < taStart && m.t > taStart - TIME_WINDOW);
|
|
35
|
+
|
|
36
|
+
if (beforeTA.length === 0) continue;
|
|
37
|
+
|
|
38
|
+
// Check if the last mouse position before tab-away was near an edge
|
|
39
|
+
const lastBefore = beforeTA[beforeTA.length - 1];
|
|
40
|
+
const nearLeftEdge = lastBefore.x < EDGE_MARGIN;
|
|
41
|
+
const nearRightEdge = lastBefore.x > width - EDGE_MARGIN;
|
|
42
|
+
|
|
43
|
+
if (nearLeftEdge || nearRightEdge) {
|
|
44
|
+
edgeExitCount++;
|
|
45
|
+
events.push({
|
|
46
|
+
tabAwayStart: taStart,
|
|
47
|
+
tabAwayDuration: ta.duration_ms,
|
|
48
|
+
exitEdge: nearLeftEdge ? 'left' : 'right',
|
|
49
|
+
exitX: lastBefore.x,
|
|
50
|
+
exitY: lastBefore.y
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { edgeExitCount, events };
|
|
56
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/cli/analyzers/summary.js
|
|
2
|
+
// Aggregates per-trial integrity signals into per-participant summaries.
|
|
3
|
+
// Produces one summary object per participant with columns for each signal category.
|
|
4
|
+
// These summaries feed into triage ranking and the summary CSV renderer.
|
|
5
|
+
|
|
6
|
+
export function computeSummary(participants, config) {
|
|
7
|
+
return participants.map(p => computeParticipantSummary(p, config));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function computeParticipantSummary(participant, config) {
|
|
11
|
+
const trials = participant.trials;
|
|
12
|
+
const n = trials.length;
|
|
13
|
+
|
|
14
|
+
// Three-way tab-away duration bins for display. The 3s boundary matches
|
|
15
|
+
// config.thresholds.tabAwayDurationMs — the scoring engine's soft-score cutoff.
|
|
16
|
+
// The 10s boundary is the visual "substantive absence" line.
|
|
17
|
+
// flicker (<3s): OS/browser noise, notification glances — no scoring penalty
|
|
18
|
+
// medium (3-10s): deliberate brief checks — count toward soft score
|
|
19
|
+
// long (≥10s): substantive absences — count toward soft score
|
|
20
|
+
const scoringCutoff_ms = config?.thresholds?.tabAwayDurationMs ?? 3000;
|
|
21
|
+
const longCutoff_ms = 10000;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
participantId: participant.participantId,
|
|
25
|
+
trialCount: n,
|
|
26
|
+
|
|
27
|
+
// Clipboard signals — paste is the strongest indicator of copy-paste from AI
|
|
28
|
+
totalPasteEvents: sum(trials, t => (t.pasteEvents || []).length),
|
|
29
|
+
totalCopyEvents: sum(trials, t => (t.copyEvents || []).length),
|
|
30
|
+
totalDropEvents: sum(trials, t => (t.dropEvents || []).length),
|
|
31
|
+
|
|
32
|
+
// Tab-away — leaving the experiment page (visibility change or blur)
|
|
33
|
+
totalTabAways: sum(trials, t => (t.tabAwayEvents || []).length),
|
|
34
|
+
tabAwayFlickerCount: sum(trials, t =>
|
|
35
|
+
(t.tabAwayEvents || []).filter(e => (e.duration_ms || 0) < scoringCutoff_ms).length),
|
|
36
|
+
tabAwayMediumCount: sum(trials, t =>
|
|
37
|
+
(t.tabAwayEvents || []).filter(e => {
|
|
38
|
+
const d = e.duration_ms || 0;
|
|
39
|
+
return d >= scoringCutoff_ms && d < longCutoff_ms;
|
|
40
|
+
}).length),
|
|
41
|
+
tabAwayLongCount: sum(trials, t =>
|
|
42
|
+
(t.tabAwayEvents || []).filter(e => (e.duration_ms || 0) >= longCutoff_ms).length),
|
|
43
|
+
totalTabAwayDuration_ms: sum(trials, t =>
|
|
44
|
+
(t.tabAwayEvents || []).reduce((s, e) => s + (e.duration_ms || 0), 0)),
|
|
45
|
+
trialsWithTabAway: trials.filter(t => (t.tabAwayEvents || []).length > 0).length,
|
|
46
|
+
|
|
47
|
+
// Typing speed — suspiciously fast typing may indicate paste or autocomplete
|
|
48
|
+
meanTypingSpeed: avg(trials, t => t.charsPerSec || 0),
|
|
49
|
+
trialsWithFastTyping: trials.filter(t =>
|
|
50
|
+
(t.charsPerSec || 0) > (config.typingSpeedThreshold_cps || 10)).length,
|
|
51
|
+
|
|
52
|
+
// Mouse — low event count or very efficient paths can indicate bot activity
|
|
53
|
+
meanMouseEvents: avg(trials, t => (t.mouseEvents || []).length),
|
|
54
|
+
meanPathEfficiency: avg(trials, t => t.mouseMetrics?.pathEfficiency || 0),
|
|
55
|
+
|
|
56
|
+
// Browser environment — AI extension or sidebar detection
|
|
57
|
+
extensionsDetected: participant.trials[0]?.extensionsDetected || [],
|
|
58
|
+
sidebarDetected: trials.some(t => (t.sidebarGapPx || 0) > 0),
|
|
59
|
+
|
|
60
|
+
// Idle gaps — long pauses during a trial (could indicate working elsewhere)
|
|
61
|
+
totalIdleGaps: sum(trials, t => (t.idleGaps || []).length),
|
|
62
|
+
|
|
63
|
+
// Synthetic insertion — text appeared without corresponding keystrokes
|
|
64
|
+
totalSyntheticInsertions: sum(trials, t => (t.syntheticInsertions || []).length),
|
|
65
|
+
|
|
66
|
+
// Foreign input — typing detected outside the experiment's response field
|
|
67
|
+
totalForeignInputEvents: sum(trials, t => (t.foreignInputEvents || []).length),
|
|
68
|
+
|
|
69
|
+
// Scoring — accumulated soft score and whether any hard signal fired
|
|
70
|
+
totalSoftScore: sum(trials, t => t.trialSoftScore || 0),
|
|
71
|
+
|
|
72
|
+
// Session-level fields (preferred over per-trial aggregation when available)
|
|
73
|
+
sidebarEventCount: participant.session?.sidebarEvents?.length ?? trials.filter(t => (t.sidebarGapPx || 0) > 0).length,
|
|
74
|
+
aiExtensionsFound: participant.session?.aiExtensionsFound || participant.trials[0]?.extensionsDetected || [],
|
|
75
|
+
keyboardShortcutCount: participant.session?.keyboardShortcuts?.length || 0,
|
|
76
|
+
layoutShiftCount: participant.session?.layoutShifts?.length || 0,
|
|
77
|
+
zoomChangeCount: participant.session?.zoomChanges?.length || 0,
|
|
78
|
+
extensionInjectionCount: participant.session?.extensionInjections?.length || 0,
|
|
79
|
+
devToolsEventCount: participant.session?.devToolsEvents?.length || 0,
|
|
80
|
+
authoritativeSoftScore: participant.score?.softScore ?? null,
|
|
81
|
+
|
|
82
|
+
// Authoritative hard-flag: prefer session's anyHardTriggered, else fall back to per-trial trialHits
|
|
83
|
+
// v3.1.0+ per-trial data: trialSignals.hard.*.trialHits counts this-trial occurrences
|
|
84
|
+
// (no .triggered boolean at per-trial level — that lives on session integrityScore).
|
|
85
|
+
// A participant is hard-flagged if any trial had any hard-signal hit.
|
|
86
|
+
hardTriggered: participant.score?.anyHardTriggered
|
|
87
|
+
?? trials.some(t =>
|
|
88
|
+
t.trialSignals?.hard && Object.values(t.trialSignals.hard).some(s => (s.trialHits || 0) > 0)),
|
|
89
|
+
|
|
90
|
+
// Pass through metadata for downstream use
|
|
91
|
+
metadata: participant.metadata || {}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Helper: sum an array by applying fn to each element
|
|
96
|
+
function sum(arr, fn) { return arr.reduce((s, x) => s + fn(x), 0); }
|
|
97
|
+
|
|
98
|
+
// Helper: average an array by applying fn to each element
|
|
99
|
+
function avg(arr, fn) { return arr.length === 0 ? 0 : sum(arr, fn) / arr.length; }
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// src/cli/analyzers/triage.js
|
|
2
|
+
// Ranks participants by suspiciousness and generates one-line reasons.
|
|
3
|
+
// This is the "start here" document for manual review — the researcher
|
|
4
|
+
// reads the triage list top-to-bottom, inspecting the most suspicious first.
|
|
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
|
|
10
|
+
|
|
11
|
+
export function rankTriage(summaries, edgeExits, config) {
|
|
12
|
+
const triageList = summaries.map((s, i) => {
|
|
13
|
+
const ee = edgeExits[i];
|
|
14
|
+
const totalEdgeExits = ee.edgeExits.reduce((sum, t) => sum + t.edgeExitCount, 0);
|
|
15
|
+
|
|
16
|
+
const score = computeTriageScore(s, totalEdgeExits);
|
|
17
|
+
const reason = generateTriageReason(s, totalEdgeExits);
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
participantId: s.participantId,
|
|
21
|
+
score,
|
|
22
|
+
reason,
|
|
23
|
+
hardTriggered: s.hardTriggered,
|
|
24
|
+
softFlagged: (s.authoritativeSoftScore ?? s.totalSoftScore) >= (config.scoring?.softScoreThreshold || 6),
|
|
25
|
+
summary: s,
|
|
26
|
+
edgeExitCount: totalEdgeExits
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Sort descending — most suspicious participants appear first
|
|
31
|
+
triageList.sort((a, b) => b.score - a.score);
|
|
32
|
+
return triageList;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Combines all signal categories into a single numeric score.
|
|
36
|
+
// Hard signals add +100 to ensure they always rank above soft-only participants.
|
|
37
|
+
// Session-level signals (sidebar events, AI extensions, keyboard shortcuts, layout
|
|
38
|
+
// shifts, zoom changes) are surfaced from session metadata when available.
|
|
39
|
+
function computeTriageScore(summary, edgeExitCount) {
|
|
40
|
+
// Prefer session-authoritative soft score; fall back to per-trial sum.
|
|
41
|
+
let score = summary.authoritativeSoftScore ?? summary.totalSoftScore;
|
|
42
|
+
if (summary.hardTriggered) score += 100;
|
|
43
|
+
score += edgeExitCount * 3;
|
|
44
|
+
|
|
45
|
+
// Session-level: +5 per AI extension detected (canonical name: aiExtensionsFound)
|
|
46
|
+
const aiExtensions = summary.aiExtensionsFound || summary.extensionsDetected || [];
|
|
47
|
+
score += aiExtensions.length * 5;
|
|
48
|
+
|
|
49
|
+
// Session-level: +3 per sidebar event, capped at 15 (≈ 5 events max contribution)
|
|
50
|
+
const sidebarCount = summary.sidebarEventCount ?? (summary.sidebarDetected ? 1 : 0);
|
|
51
|
+
score += Math.min(sidebarCount * 3, 15);
|
|
52
|
+
|
|
53
|
+
// Session-level: +2 per DevTools-ish keyboard shortcut
|
|
54
|
+
score += (summary.keyboardShortcutCount || 0) * 2;
|
|
55
|
+
|
|
56
|
+
// Session-level: +1 per layout shift / zoom change (workspace perturbations)
|
|
57
|
+
score += (summary.layoutShiftCount || 0);
|
|
58
|
+
score += (summary.zoomChangeCount || 0);
|
|
59
|
+
|
|
60
|
+
score += summary.totalSyntheticInsertions * 4;
|
|
61
|
+
score += summary.totalForeignInputEvents * 3;
|
|
62
|
+
return score;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Generates a human-readable one-line summary of why this participant was flagged.
|
|
66
|
+
// Each contributing signal is listed with its count.
|
|
67
|
+
export function generateTriageReason(summary, edgeExitCount = 0) {
|
|
68
|
+
const parts = [];
|
|
69
|
+
if (summary.totalPasteEvents > 0) parts.push(`${summary.totalPasteEvents} paste events`);
|
|
70
|
+
if (summary.totalDropEvents > 0) parts.push(`${summary.totalDropEvents} drop events`);
|
|
71
|
+
if (summary.totalCopyEvents > 0) parts.push(`${summary.totalCopyEvents} copy events`);
|
|
72
|
+
// Three-way tab-away split when the analyzer produced it. The ≥3s bins feed the
|
|
73
|
+
// soft score and lead the reason; flickers are surfaced separately so researchers
|
|
74
|
+
// can see the noise floor without it inflating the apparent severity.
|
|
75
|
+
if (summary.tabAwayLongCount != null || summary.tabAwayMediumCount != null ||
|
|
76
|
+
summary.tabAwayFlickerCount != null) {
|
|
77
|
+
const longN = summary.tabAwayLongCount || 0;
|
|
78
|
+
const midN = summary.tabAwayMediumCount || 0;
|
|
79
|
+
const flickN = summary.tabAwayFlickerCount || 0;
|
|
80
|
+
if (longN > 0) parts.push(`${longN} tab-away${longN === 1 ? '' : 's'} ≥10s`);
|
|
81
|
+
if (midN > 0) parts.push(`${midN} tab-away${midN === 1 ? '' : 's'} 3–10s`);
|
|
82
|
+
if (flickN > 0) parts.push(`${flickN} flicker${flickN === 1 ? '' : 's'} <3s`);
|
|
83
|
+
} else if (summary.totalTabAways > 0) {
|
|
84
|
+
parts.push(`${summary.totalTabAways} tab-aways`);
|
|
85
|
+
}
|
|
86
|
+
if (summary.trialsWithFastTyping > 0) parts.push(`fast typing on ${summary.trialsWithFastTyping} trials`);
|
|
87
|
+
// Prefer canonical aiExtensionsFound; fall back to legacy extensionsDetected for tests.
|
|
88
|
+
const aiExtensions = summary.aiExtensionsFound || summary.extensionsDetected || [];
|
|
89
|
+
if (aiExtensions.length > 0) {
|
|
90
|
+
const names = aiExtensions.map(e => typeof e === 'string' ? e : (e.name || 'unknown'));
|
|
91
|
+
parts.push(names.join(', ') + ' detected');
|
|
92
|
+
}
|
|
93
|
+
const sidebarCount = summary.sidebarEventCount ?? (summary.sidebarDetected ? 1 : 0);
|
|
94
|
+
if (sidebarCount > 0) parts.push(`${sidebarCount} sidebar event${sidebarCount === 1 ? '' : 's'}`);
|
|
95
|
+
if (summary.keyboardShortcutCount > 0) parts.push(`${summary.keyboardShortcutCount} keyboard shortcuts`);
|
|
96
|
+
if (summary.layoutShiftCount > 0) parts.push(`${summary.layoutShiftCount} layout shifts`);
|
|
97
|
+
if (summary.zoomChangeCount > 0) parts.push(`${summary.zoomChangeCount} zoom changes`);
|
|
98
|
+
if (summary.devToolsEventCount > 0) parts.push(`${summary.devToolsEventCount} devtools events`);
|
|
99
|
+
if (edgeExitCount > 0) parts.push(`${edgeExitCount} edge-exit patterns`);
|
|
100
|
+
if (summary.totalSyntheticInsertions > 0) parts.push(`${summary.totalSyntheticInsertions} synthetic insertions`);
|
|
101
|
+
if (summary.totalForeignInputEvents > 0) parts.push(`${summary.totalForeignInputEvents} foreign inputs`);
|
|
102
|
+
if (parts.length === 0) parts.push('clean');
|
|
103
|
+
return parts.join('; ');
|
|
104
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/cli/config.js
|
|
2
|
+
// Loads and merges config from: defaults → config file → CLI args.
|
|
3
|
+
//
|
|
4
|
+
// Resolution order (later wins):
|
|
5
|
+
// 1. DEFAULT_CLI_CONFIG from schema.js — sensible fallbacks
|
|
6
|
+
// 2. cyborg-hunter.config.json (or --config path) — project-specific
|
|
7
|
+
// 3. CLI flags (--data, --output, etc.) — ad-hoc overrides
|
|
8
|
+
|
|
9
|
+
import { readFileSync, existsSync } from 'fs';
|
|
10
|
+
import { resolve, join } from 'path';
|
|
11
|
+
import { DEFAULT_CLI_CONFIG } from '../shared/schema.js';
|
|
12
|
+
import { validateConfig } from '../shared/validation.js';
|
|
13
|
+
|
|
14
|
+
export function loadConfig(cliArgs) {
|
|
15
|
+
// Parse CLI flags into a simple key-value object
|
|
16
|
+
const flags = parseFlags(cliArgs);
|
|
17
|
+
|
|
18
|
+
// Load config file (default: cyborg-hunter.config.json in cwd)
|
|
19
|
+
const configPath = flags.config || join(process.cwd(), 'cyborg-hunter.config.json');
|
|
20
|
+
let fileConfig = {};
|
|
21
|
+
if (existsSync(configPath)) {
|
|
22
|
+
try {
|
|
23
|
+
fileConfig = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
24
|
+
console.log(`Loaded config from ${configPath}`);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.warn(`Warning: Failed to parse ${configPath}: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Merge: defaults ← file config ← CLI flags
|
|
31
|
+
const config = { ...DEFAULT_CLI_CONFIG, ...fileConfig };
|
|
32
|
+
if (flags.data) config.dataDir = flags.data;
|
|
33
|
+
if (flags.output) config.outputDir = flags.output;
|
|
34
|
+
if (flags.participant) config.singleParticipant = flags.participant;
|
|
35
|
+
if (flags.participantIdField) config.participantIdField = flags.participantIdField;
|
|
36
|
+
if (flags.filePattern) config.filePattern = flags.filePattern;
|
|
37
|
+
if (flags.integrityField) config.integrityField = flags.integrityField;
|
|
38
|
+
if (flags['no-visuals']) config.noVisuals = true;
|
|
39
|
+
|
|
40
|
+
// Resolve relative paths to absolute (relative to cwd)
|
|
41
|
+
config.dataDir = resolve(config.dataDir);
|
|
42
|
+
config.outputDir = resolve(config.outputDir);
|
|
43
|
+
|
|
44
|
+
// Validate config file keys — warns about typos
|
|
45
|
+
const warnings = validateConfig(fileConfig);
|
|
46
|
+
warnings.forEach(w => console.warn(`Warning: ${w}`));
|
|
47
|
+
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Parses CLI arguments into a flags object.
|
|
52
|
+
//
|
|
53
|
+
// Supported flags (kebab-case aliases match the camelCase config keys, so
|
|
54
|
+
// you can use whichever form you remember):
|
|
55
|
+
// --config <path> # alternate config file path
|
|
56
|
+
// --data, --data-dir <path> # config.dataDir
|
|
57
|
+
// --output, --output-dir <path> # config.outputDir
|
|
58
|
+
// --participant <id> # filter to single participant
|
|
59
|
+
// --participant-id-field <name> # config.participantIdField
|
|
60
|
+
// --file-pattern <glob> # config.filePattern
|
|
61
|
+
// --integrity-field <name> # config.integrityField
|
|
62
|
+
// --no-visuals # skip canvas-rendered images
|
|
63
|
+
//
|
|
64
|
+
// Throws on unknown flags. Earlier behavior was to print a warning and keep
|
|
65
|
+
// going, which silently masked typos and caused the CLI to fall back to a
|
|
66
|
+
// completely different config file than the user intended.
|
|
67
|
+
export function parseFlags(args) {
|
|
68
|
+
const flags = {};
|
|
69
|
+
// Single-arg flags map alias → flags key. The flags key is the camelCase
|
|
70
|
+
// form so loadConfig can look it up directly.
|
|
71
|
+
const SINGLE_VALUE = {
|
|
72
|
+
'--config': 'config',
|
|
73
|
+
'--data': 'data',
|
|
74
|
+
'--data-dir': 'data',
|
|
75
|
+
'--output': 'output',
|
|
76
|
+
'--output-dir': 'output',
|
|
77
|
+
'--participant': 'participant',
|
|
78
|
+
'--participant-id-field': 'participantIdField',
|
|
79
|
+
'--file-pattern': 'filePattern',
|
|
80
|
+
'--integrity-field': 'integrityField',
|
|
81
|
+
};
|
|
82
|
+
for (let i = 0; i < args.length; i++) {
|
|
83
|
+
const a = args[i];
|
|
84
|
+
if (SINGLE_VALUE[a] && args[i+1] !== undefined) {
|
|
85
|
+
flags[SINGLE_VALUE[a]] = args[++i];
|
|
86
|
+
} else if (a === '--no-visuals') {
|
|
87
|
+
flags['no-visuals'] = true;
|
|
88
|
+
} else if (a === 'report') {
|
|
89
|
+
// Subcommand keyword; the dispatcher in bin/cyborg-hunter.js already
|
|
90
|
+
// routed on this. Skip rather than treating it as unknown.
|
|
91
|
+
} else {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Unknown argument: ${a}\n` +
|
|
94
|
+
` Run \`cyborg-hunter --help\` for the full flag list.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return flags;
|
|
99
|
+
}
|