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.
@@ -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
+ }