import test from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import vm from 'node:vm'; import { defaultPolicy } from '../packages/core/policy.mjs'; const require=createRequire(import.meta.url),{createClipboardGuard}=require('../apps/browser/clipboard-guard.cjs'),{clipboardScript}=require('../apps/browser/clipboard-dom.cjs'); const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return{promise,resolve};}; function guardFixture(inspect){ const frame={},webContents={mainFrame:frame,focusedFrame:frame};let context={ready:true,policy:defaultPolicy(),url:'https://company.example/login',navigationId:1,webContents};const writes=[],commits=[],discards=[],audit=[]; const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:'public selected text'}),validate:async()=>({completed:true}),commit:async args=>{commits.push(args);return{completed:true};},discard:async args=>discards.push(args),readClipboard:()=> 'public pasted text',writeClipboard:text=>writes.push(text),inspect,audit:async(...args)=>audit.push(args),timeoutMs:25}); return{guard,webContents,writes,commits,discards,audit,context:()=>context,setContext:c=>{context=c;}}; } test('independent clipboard review: changed policy, navigation or readiness prevents release after scanning',async()=>{ for(const changed of ['policy','url','navigationId','ready']){const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;});const operation=f.guard.perform('copy',f.webContents);await started.promise;const context={...f.context()};context[changed]=changed==='policy'?{...context.policy,version:2}:changed==='url'?'https://other.example/':changed==='navigationId'?2:false;f.setContext(context);pending.resolve({decision:'allow'});assert.equal((await operation).completed,false,changed);assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);assert.equal(f.discards.length,1);assert.equal(JSON.stringify(f.audit).includes('public selected text'),false);} }); test('independent clipboard review: timeout does not authorize delayed inspection completion',async()=>{ const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;});const operation=f.guard.perform('paste',f.webContents);await started.promise;const result=await operation;assert.equal(result.ruleId,'clipboard-timeout');pending.resolve({decision:'allow'});await new Promise(r=>setImmediate(r));assert.equal(f.writes.length,0);assert.equal(f.commits.length,0);assert.ok(f.discards.length>=1); }); test('switching away and back during scan invalidates clipboard authorization',async()=>{ const pending=deferred(),started=deferred(),f=guardFixture(async()=>{started.resolve();return pending.promise;}); f.setContext({...f.context(),tabId:'tab-a',activation:1}); const operation=f.guard.perform('copy',f.webContents);await started.promise; f.setContext({...f.context(),tabId:'tab-a',activation:3}); pending.resolve({decision:'allow'});assert.equal((await operation).completed,false); assert.equal(f.writes.length,0);assert.equal(f.commits.length,0); }); test('independent clipboard review: focused nested frame cannot request a main-frame copy',async()=>{ const f=guardFixture(async()=>({decision:'allow'}));f.webContents.focusedFrame={};assert.equal((await f.guard.perform('copy',f.webContents)).completed,false);assert.equal(f.writes.length,0);assert.equal(f.commits.length,0); }); test('independent clipboard review: failed native clipboard write cannot delete a cut selection',async()=>{ const frame={},webContents={mainFrame:frame,focusedFrame:frame},context={ready:true,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents};let mutations=0,discarded=0; const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:'selected public text'}),validate:async()=>({completed:true}),commit:async()=>{mutations++;return{completed:true};},discard:async()=>{discarded++;},readClipboard:()=>'',writeClipboard:()=>{throw Error('Native write failed');},inspect:async()=>({decision:'allow'}),audit:async()=>{}}); assert.equal((await guard.perform('cut',webContents)).completed,false);assert.equal(mutations,0);assert.equal(discarded,1); }); function domFixture(){ const matches=(node,selector)=>{selector=selector.trim();if(selector.startsWith('[')){const match=/^\[([^=\]]+)(?:="([^"]+)")?\]$/.exec(selector);return !!match&&Object.hasOwn(node.attributes,match[1])&&(match[2]===undefined||node.attributes[match[1]]===match[2]);}return node.tagName.toLowerCase()===selector;}; const element=(tag,parent=null,attributes={})=>{const e={nodeType:1,tagName:tag.toUpperCase(),parentElement:parent,attributes,children:[],isConnected:true,type:attributes.type||'',style:{display:'block',visibility:'visible',opacity:'1'},getAttribute:name=>e.attributes[name]??null,getClientRects:()=>[{width:100,height:16}],closest:selector=>{for(let p=e;p;p=p.parentElement)if(selector.split(',').some(s=>matches(p,s)))return p;return null;},querySelector:selector=>descendants(e).find(n=>selector.split(',').some(s=>matches(n,s)))||null};parent?.children.push(e);return e;}; const descendants=e=>e.children.flatMap(c=>[c,...descendants(c)]),body=element('body'),paragraph=element('p',body),child=element('span',paragraph),range={commonAncestorContainer:paragraph,startContainer:paragraph,endContainer:paragraph,startOffset:0,endOffset:1,toString:()=> 'public text with selected hidden content',cloneRange(){return{...this};},intersectsNode:()=>true},selection={rangeCount:1,isCollapsed:false,getRangeAt:()=>range}; const document={body,activeElement:body,createTreeWalker:root=>{const nodes=descendants(root);let index=0;return{nextNode:()=>nodes[index++]||null};}},window={};window.top=window; const sandbox={window,document,location:{href:'https://company.example/'},NodeFilter:{SHOW_ELEMENT:1},getSelection:()=>selection,getComputedStyle:e=>e.style,Event:class{}};vm.createContext(sandbox); const execute=(mode='stage',operation='copy')=>vm.runInContext(clipboardScript(mode,{url:sandbox.location.href,token:'trusted-token',operation,text:'public replacement',limit:1024}),sandbox); return{sandbox,document,paragraph,child,element,range,execute}; } test('independent clipboard review: selected hidden, inert, script and ancestor-transparent content is refused',()=>{ for(const type of ['hidden','inert','aria-hidden','script','transparent']){const f=domFixture();if(type==='script')f.child.tagName='SCRIPT';else if(type==='transparent')f.paragraph.style.opacity='0';else f.child.attributes[type]=type==='aria-hidden'?'true':'';assert.throws(()=>f.execute(),undefined,type);assert.equal(f.sandbox.__safetynetClipboardSnapshot,undefined);} const publicRange=domFixture();assert.equal(publicRange.execute().text,'public text with selected hidden content');publicRange.child.attributes.hidden='';assert.throws(()=>publicRange.execute('commit'));assert.equal(publicRange.sandbox.__safetynetClipboardSnapshot,undefined); }); test('independent clipboard review: password fields and parent-hidden text fields never reach the scanner',()=>{ for(const type of ['password','secret-name','transparent']){const f=domFixture(),input=f.element('input',f.paragraph,{type:type==='password'?'password':'text',...(type==='secret-name'?{name:'api_token'}:{})});Object.assign(input,{value:'private field secret',selectionStart:0,selectionEnd:20,disabled:false,readOnly:false});f.document.activeElement=input;if(type==='transparent')f.paragraph.style.opacity='0';assert.throws(()=>f.execute());assert.equal(f.sandbox.__safetynetClipboardSnapshot,undefined);} }); test('clipboard completion metadata follows actual commit and native write, never exposes content',async()=>{ const frame={},wc={mainFrame:frame,focusedFrame:frame},steps=[],events=[]; let c={ready:true,tabId:'71911cdc-a390-4e73-a604-d98d05fd52a5',activation:1,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents:wc}; const options={getContext:()=>c,stage:async()=>({text:'safe text',target:{tag:'input',x:10,y:20,text:'SECRET',selector:'#secret'}}),validate:async()=>({completed:true}),commit:async()=>{steps.push('commit');return{completed:true};},readClipboard:async()=> 'safe paste',writeClipboard:async()=>steps.push('write'),inspect:async()=>({decision:'allow'}),audit:async(kind,rule,facts)=>{steps.push('audit');assert.equal(JSON.stringify(facts).includes('safe text'),false);},onCompleted:async metadata=>{steps.push('completed');events.push(metadata);}}; const guard=createClipboardGuard(options);assert.equal((await guard.perform('copy',wc)).completed,true); assert.deepEqual(steps,['audit','commit','write','completed']);assert.deepEqual(events,[{action:'copy',sessionId:c.tabId,pageUrl:c.url,outcome:'completed',sensitiveField:false,target:{tag:'input',x:10,y:20},bytes:9}]); events.length=0;options.inspect=async()=>({decision:'block',ruleId:'blocked'});assert.equal((await createClipboardGuard(options).perform('paste',wc)).completed,false);assert.equal(events.length,0); options.inspect=async()=>({decision:'allow'});options.writeClipboard=async()=>{c={...c,navigationId:c.navigationId+1};};assert.equal((await createClipboardGuard(options).perform('copy',wc)).completed,false);assert.equal(events.length,0); }); test('paste rereads external clipboard per gesture and never swaps approved bytes during asynchronous scan',async()=>{ const frame={},wc={mainFrame:frame,focusedFrame:frame};let clipboard='First public clipboard',context={ready:true,tabId:'71911cdc-a390-4e73-a604-d98d05fd52a5',activation:1,policy:defaultPolicy(),url:'https://company.example/',navigationId:1,webContents:wc}; const scanned=[],inserted=[],events=[];let waiting=deferred(),started=deferred(); const guard=createClipboardGuard({getContext:()=>context,stage:async()=>({text:''}),validate:async()=>({completed:true}),commit:async args=>{inserted.push(args.text);return {completed:true};},discard:async()=>{},readClipboard:async()=>clipboard,writeClipboard:async()=>{},inspect:async bytes=>{scanned.push(bytes.toString());started.resolve();return waiting.promise;},audit:async(kind,ruleId,facts)=>events.push({kind,ruleId,facts})}); const first=guard.perform('paste',wc);await started.promise;clipboard='Secret Roadmap changed by external app';waiting.resolve({decision:'allow'});assert.equal((await first).completed,true);assert.deepEqual(inserted,['First public clipboard']); waiting=deferred();started=deferred();const second=guard.perform('paste',wc);await started.promise;assert.equal(scanned.at(-1),clipboard);waiting.resolve({decision:'block',ruleId:'company-secret'});assert.equal((await second).completed,false);assert.deepEqual(inserted,['First public clipboard']); clipboard='Third public clipboard';waiting=deferred();started=deferred();const third=guard.perform('paste',wc);await started.promise;context={...context,policy:{...context.policy,version:context.policy.version+1}};waiting.resolve({decision:'allow'});assert.equal((await third).completed,false);assert.deepEqual(inserted,['First public clipboard']); assert.ok(events.some(e=>e.kind==='clipboard.paste'&&e.facts.action==='paste'&&e.facts.sessionId===context.tabId&&e.facts.outcome==='authorized')); assert.ok(events.some(e=>e.kind==='clipboard.block'&&e.ruleId==='company-secret')); assert.equal(JSON.stringify(events).includes('clipboard changed by external'),false);assert.equal(JSON.stringify(events).includes('Secret Roadmap'),false);assert.equal(JSON.stringify(events).includes('First public clipboard'),false); });