Files
safetynet/tests/agent-visual-export.test.mjs
T

23 lines
9.2 KiB
JavaScript
Raw Normal View History

import {AsyncLocalStorage} from 'node:async_hooks';
import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {deflateSync} from 'node:zlib';const require=createRequire(import.meta.url),{createAgentVisualExport,getVisualReleaseGuard,validateVisualResult,pngSize}=require('../apps/browser/agent-visual-export.cjs');
function png(width=2,height=2,metadata){const crc=bytes=>{let c=0xffffffff;for(const b of bytes){c^=b;for(let i=0;i<8;i++)c=(c&1)?0xedb88320^(c>>>1):c>>>1;}return (c^0xffffffff)>>>0;},chunk=(type,data)=>{const size=Buffer.alloc(4),name=Buffer.from(type),sum=Buffer.alloc(4);size.writeUInt32BE(data.length);sum.writeUInt32BE(crc(Buffer.concat([name,data])));return Buffer.concat([size,name,data,sum]);};const ihdr=Buffer.alloc(13);ihdr.writeUInt32BE(width);ihdr.writeUInt32BE(height,4);ihdr[8]=8;ihdr[9]=6;return Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',ihdr),chunk('IDAT',deflateSync(Buffer.alloc(18))),...(metadata?[chunk('tEXt',Buffer.from(metadata))]:[]),chunk('IEND',Buffer.alloc(0))]);}
function fixture(){const bytes=png(),events=[],stages=[],policy={expiresAt:new Date(Date.now()+60000).toISOString(),agents:{enabled:true,scopes:['screenshot']}},c={ready:true,foreground:true,interactive:true,policy,sessionId:'tab1',token:{},actor:{agentId:'actor',generation:1},webContents:{getURL:()=> 'https://example.com/docs',isDestroyed:()=>false},destinationAllowed:()=>true};let revision='rev1',safe=true,valid=true;const deps={getContext:()=>c,contextCurrent:()=>valid,probeSensitive:async()=>({safe,revision,width:2,height:2}),capture:async()=>bytes,inspectLocal:async()=>({decision:'allow',contentType:'image',coverage:{complete:true}}),inspectEnterprise:async()=>({decision:'allow',coverage:{complete:true}}),audit:async e=>events.push(e),traceOperation:async(op,fn)=>{stages.push(op);return fn();}};return {c,bytes,deps,events,stages,changeRevision:()=>revision='rev2',unsafe:()=>safe=false,invalidate:()=>valid=false,make:()=>createAgentVisualExport(deps)};}
test('native PNG export requires OCR and enterprise allow, exports only canonical bounded payload',async()=>{const f=fixture(),expected=f.bytes.toString('base64'),result=await f.make()();assert.deepEqual(result,{mime:'image/png',width:2,height:2,data:expected});assert.ok(f.bytes.every(b=>b===0));assert.deepEqual(f.events,[{action:'screenshot',outcome:'authorized',sessionId:'tab1'}]);assert.deepEqual(f.stages.filter(s=>s!=='visual.probe'),['visual.capture','visual.validate','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.project']);});
test('scope defaults off and unsafe visible fields/frames deny before capture',async()=>{for(const mode of ['scope','unsafe','foreground','interactive']){const f=fixture();let captured=0;f.deps.capture=async()=>{captured++;return f.bytes;};if(mode==='scope')f.c.policy.agents.scopes=[];if(mode==='unsafe')f.unsafe();if(mode==='foreground')f.c.foreground=false;if(mode==='interactive')f.c.interactive=false;await assert.rejects(f.make()());assert.equal(captured,0);}});
test('local image OCR is mandatory and incomplete/blocked/nonimage verdicts fail closed',async()=>{for(const verdict of [{decision:'block',coverage:{complete:true}},{decision:'allow',contentType:'image',coverage:{complete:false}},{decision:'allow',contentType:'text',coverage:{complete:true}}]){const f=fixture();f.deps.inspectLocal=async()=>verdict;await assert.rejects(f.make()());assert.ok(f.bytes.every(b=>b===0));assert.equal(f.events.length,0);}});
test('capture/local/enterprise/audit waits reject changed native authority and content revision',async()=>{for(const stage of ['capture','inspectLocal','inspectEnterprise','audit'])for(const change of ['policy','actor','revision']){const f=fixture(),fn=f.deps[stage];f.deps[stage]=async(...args)=>{const result=await fn(...args);if(change==='policy')f.c.policy={...f.c.policy};if(change==='actor')f.c.actor={agentId:'other',generation:1};if(change==='revision')f.changeRevision();return result;};await assert.rejects(f.make()());assert.ok(f.bytes.every(b=>b===0));}});
test('PNG source mutation, malformed CRC, dimensions and arbitrary arguments are rejected',async()=>{const f=fixture();f.deps.inspectEnterprise=async()=>{f.bytes[40]^=1;return {decision:'allow',coverage:{complete:true}};};await assert.rejects(f.make()());for(const data of [png(4097,2),png(4096,4096),Buffer.from('not-png')])assert.throws(()=>pngSize(data));const malformed=png();malformed[32]^=1;assert.throws(()=>pngSize(malformed));const g=fixture();await assert.rejects(g.make()({rect:{x:0,y:0,width:10,height:10}}));});
test('all failures sanitize replies and zero acquired buffers',async()=>{const f=fixture();f.deps.inspectLocal=async()=>{throw Error('PRIVATE IMAGE DATA');};await assert.rejects(f.make()(),{message:'Managed visual export refused'});assert.ok(f.bytes.every(b=>b===0));});
test('diagnostic failures and duplicate invocations never replay capture or alter output',async()=>{for(const mode of ['before','after','twice']){const f=fixture();let captures=0;f.deps.capture=async()=>{captures++;return f.bytes;};f.deps.traceOperation=async(_op,fn)=>{if(mode==='before')throw Error('Diagnostic failure');const result=await fn();if(mode==='after')throw Error('Diagnostic failure');await fn();return result;};assert.equal((await f.make()()).mime,'image/png');assert.equal(captures,1);assert.ok(f.bytes.every(b=>b===0));}});
test('aborted local inspection prevents enterprise inspection and wipes captured PNG',async()=>{const f=fixture(),controller=new AbortController();let enterprise=0;f.deps.inspectLocal=async()=>{controller.abort();return {decision:'allow',contentType:'image',coverage:{complete:true}};};f.deps.inspectEnterprise=async()=>{enterprise++;return {decision:'allow',coverage:{complete:true}};};await assert.rejects(f.make()({}, {signal:controller.signal}));assert.equal(enterprise,0);assert.ok(f.bytes.every(b=>b===0));const g=fixture();await assert.rejects(g.make()({}, {signal:AbortSignal.abort()}));});
test('pure visual projection requires canonical PNG and rejects mismatched or extra metadata',()=>{const bytes=png(),result={mime:'image/png',width:2,height:2,data:bytes.toString('base64')};assert.deepEqual(validateVisualResult(result),result);for(const value of [{...result,width:3},{...result,data:result.data+'\n'},{...result,url:'https://secret.example'},{...result,mime:'image/jpeg'}])assert.throws(()=>validateVisualResult(value));let reads=0;const hostile={...result};Object.defineProperty(hostile,'data',{get(){reads++;return result.data;}});assert.throws(()=>validateVisualResult(hostile));assert.equal(reads,0);});
test('PNG ancillary content-bearing chunks are denied before export',()=>{const validPngWithText=png(2,2,'comment\\0PRIVATE_METADATA');assert.throws(()=>pngSize(validPngWithText));});
test('private release guard invalidates policy, actor, token, navigation and cancellation after helper return',async()=>{for(const mode of ['policy','actor','token','navigation','abort']){const f=fixture(),controller=new AbortController();const result=await f.make()({}, {signal:controller.signal}),guard=getVisualReleaseGuard(result);assert.equal(typeof guard,'function');assert.equal(Object.isFrozen(result),true);assert.deepEqual(Object.keys(result).sort(),['data','height','mime','width']);guard();if(mode==='policy')f.c.policy={...f.c.policy};if(mode==='actor')f.c.actor={agentId:'other',generation:1};if(mode==='token')f.invalidate();if(mode==='navigation')f.c.webContents.getURL=()=> 'https://example.com/changed';if(mode==='abort')controller.abort();assert.throws(guard,e=>e.status===403);assert.equal(getVisualReleaseGuard({...result}),undefined);assert.equal(getVisualReleaseGuard(JSON.parse(JSON.stringify(result))),undefined);}});
test('private release guard restores original actor async context when transport calls outside it',async()=>{const f=fixture(),actors=new AsyncLocalStorage();let currentActor={agentId:'original-agent',generation:1};f.deps.getContext=()=>({...f.c,actor:actors.getStore()});const result=await actors.run(currentActor,()=>f.make()()),guard=getVisualReleaseGuard(result);assert.equal(actors.getStore(),undefined);assert.doesNotThrow(guard);f.c.policy={...f.c.policy};assert.throws(guard,e=>e.status===403);});
test('native frame identity, process/routing and loading state invalidate private release guard',async()=>{for(const mode of ['frame','process','routing','loading']){const f=fixture();f.c.webContents.mainFrame={processId:101,routingId:202,isDestroyed:()=>false};let loading=false;f.c.webContents.isLoading=()=>loading;const result=await f.make()(),guard=getVisualReleaseGuard(result);guard();if(mode==='frame')f.c.webContents.mainFrame={...f.c.webContents.mainFrame};if(mode==='process')f.c.webContents.mainFrame.processId++;if(mode==='routing')f.c.webContents.mainFrame.routingId++;if(mode==='loading')loading=true;assert.throws(guard,e=>e.status===403);}});
test('same URL native navigation during inspection cannot export a stale screenshot',async()=>{const f=fixture();let loading=false;f.c.webContents.isLoading=()=>loading;f.c.webContents.mainFrame={processId:1,routingId:2,isDestroyed:()=>false};f.deps.inspectEnterprise=async()=>{loading=true;return {decision:'allow',coverage:{complete:true}};};await assert.rejects(f.make()(),e=>e.status===403);assert.ok(f.bytes.every(b=>b===0));});