Files

20 lines
6.9 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
const {createHash}=require('node:crypto');
const {AsyncResource}=require('node:async_hooks');
const releaseGuards=new WeakMap();
const getVisualReleaseGuard=result=>result&&typeof result==='object'?releaseGuards.get(result):undefined;
const refused=()=>{const error=Error('Managed visual export refused');error.status=403;throw error;};
const MAX_BYTES=2*1024*1024,MAX_DIMENSION=4096,MAX_PIXELS=8*1024*1024;
const crcTable=Array.from({length:256},(_,n)=>{let c=n;for(let i=0;i<8;i++)c=(c&1)?0xedb88320^(c>>>1):c>>>1;return c>>>0;});
function crc32(bytes,start,end){let c=0xffffffff;for(let i=start;i<end;i++)c=crcTable[(c^bytes[i])&255]^(c>>>8);return (c^0xffffffff)>>>0;}
function pngSize(bytes){if(!Buffer.isBuffer(bytes)||bytes.length<57||bytes.length>MAX_BYTES||!bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])))refused();let offset=8,dimensions,idat=false,end=false,chunks=0;while(offset<bytes.length){if(++chunks>4096||offset+12>bytes.length)refused();const size=bytes.readUInt32BE(offset),type=bytes.toString('ascii',offset+4,offset+8);if(!['IHDR','IDAT','IEND','sRGB'].includes(type)||size>bytes.length-offset-12)refused();if(type==='sRGB'&&(size!==1||bytes[offset+8]>3||idat))refused();if(crc32(bytes,offset+4,offset+8+size)!==bytes.readUInt32BE(offset+8+size))refused();if(!dimensions){if(type!=='IHDR'||size!==13)refused();const width=bytes.readUInt32BE(offset+8),height=bytes.readUInt32BE(offset+12);if(!width||!height||width>MAX_DIMENSION||height>MAX_DIMENSION||width*height>MAX_PIXELS||bytes[offset+16]!==8||![2,6].includes(bytes[offset+17])||bytes[offset+18]!==0||bytes[offset+19]!==0||bytes[offset+20]>1)refused();dimensions={width,height};}else if(type==='IHDR')refused();if(type==='IDAT')idat=true;if(type==='IEND'){if(size||offset+12!==bytes.length)refused();end=true;}offset+=12+size;if(end)break;}if(!end||!idat)refused();return dimensions;}
function validateVisualResult(input){let bytes;try{if(!input||typeof input!=='object'||Array.isArray(input)||Object.getPrototypeOf(input)!==Object.prototype)refused();const descriptors=Object.getOwnPropertyDescriptors(input);if(Object.keys(descriptors).sort().join(',')!=='data,height,mime,width'||Object.values(descriptors).some(d=>!('value'in d)))refused();const {mime,width,height,data}=input;if(mime!=='image/png'||typeof data!=='string'||data.length>Math.ceil(MAX_BYTES/3)*4||data.length%4||!data.length||!/^[A-Za-z0-9+/]*={0,2}$/.test(data))refused();bytes=Buffer.from(data,'base64');if(bytes.toString('base64')!==data)refused();const size=pngSize(bytes);if(size.width!==width||size.height!==height)refused();return {mime,width,height,data};}finally{bytes?.fill(0);}}
function createAgentVisualExport({getContext,contextCurrent,probeSensitive,capture,inspectLocal,inspectEnterprise,audit,traceOperation=async(_operation,fn)=>fn()}={}){
if([getContext,contextCurrent,probeSensitive,capture,inspectLocal,inspectEnterprise,audit].some(fn=>typeof fn!=='function'))throw TypeError('Visual export callbacks required');
const stage=async(operation,fn)=>{let called=false,result;const once=()=>{if(called)return result;called=true;try{result=Promise.resolve(fn());}catch(error){result=Promise.reject(error);}return result;};try{await traceOperation(operation,once);}catch{}if(!called)once();return await result;};
const snapshot=(args,signal)=>{if(!args||typeof args!=='object'||Array.isArray(args)||Object.keys(args).some(k=>k!=='sessionId'))refused();const c=getContext(args.sessionId);if(signal?.aborted||!c?.ready||!c.token||!c.webContents||c.webContents.isDestroyed?.()||c.webContents.isLoading?.()||c.foreground!==true||c.interactive!==true||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes('screenshot')||!Number.isFinite(Date.parse(c.policy.expiresAt))||Date.parse(c.policy.expiresAt)<=Date.now()||!c.actor||typeof c.actor.agentId!=='string'||!Number.isSafeInteger(c.actor.generation))refused();const url=c.webContents.getURL();let u;try{u=new URL(url);}catch{refused();}if(u.protocol!=='https:'||u.username||u.password||typeof c.destinationAllowed!=='function'||c.destinationAllowed(url,c.policy)!==true)refused();const frame=c.webContents.mainFrame;if(frame&&frame.isDestroyed?.())refused();return {...c,url,actorBinding:JSON.stringify(c.actor),nativeFrame:frame,nativeProcessId:frame?.processId,nativeRoutingId:frame?.routingId};};
const current=(c,args,signal)=>{const n=snapshot(args,signal);if(n.policy!==c.policy||n.webContents!==c.webContents||n.sessionId!==c.sessionId||n.actorBinding!==c.actorBinding||n.url!==c.url||n.nativeFrame!==c.nativeFrame||n.nativeProcessId!==c.nativeProcessId||n.nativeRoutingId!==c.nativeRoutingId||!contextCurrent(c.token))refused();};
const probe=async(c,args,signal,expected)=>stage('visual.probe',async()=>{const p=await probeSensitive(c);current(c,args,signal);if(!p||p.safe!==true||typeof p.revision!=='string'||!p.revision||p.revision.length>128||!Number.isSafeInteger(p.width)||!Number.isSafeInteger(p.height)||p.width<1||p.height<1||p.width>MAX_DIMENSION||p.height>MAX_DIMENSION||p.width*p.height>MAX_PIXELS)refused();const value={revision:p.revision,width:p.width,height:p.height};if(expected&&JSON.stringify(value)!==JSON.stringify(expected))refused();return value;});
return async function screenshot(args={}, {signal}={}){let bytes;try{const c=snapshot(args,signal);current(c,args,signal);const baseline=await probe(c,args,signal);bytes=await stage('visual.capture',async()=>{const value=await capture(c);bytes=value;current(c,args,signal);if(!Buffer.isBuffer(value)||value.length>MAX_BYTES)refused();return value;});const size=await stage('visual.validate',()=>pngSize(bytes));current(c,args,signal);const binding=createHash('sha256').update(bytes).digest('hex');await probe(c,args,signal,baseline);const inspectPolicy=c.policy;await stage('visual.inspect.local',async()=>{const local=await inspectLocal(bytes,inspectPolicy,c);current(c,args,signal);if(local?.decision!=='allow'||local.contentType!=='image'||local.coverage?.complete!==true)refused();});await probe(c,args,signal,baseline);await stage('visual.inspect.enterprise',async()=>{const enterprise=await inspectEnterprise(bytes,inspectPolicy,c);current(c,args,signal);if(enterprise?.decision!=='allow'||enterprise.coverage?.complete!==true)refused();});await probe(c,args,signal,baseline);if(createHash('sha256').update(bytes).digest('hex')!==binding)refused();await stage('visual.audit',()=>audit({action:'screenshot',outcome:'authorized',sessionId:c.sessionId}));current(c,args,signal);await probe(c,args,signal,baseline);if(createHash('sha256').update(bytes).digest('hex')!==binding)refused();return await stage('visual.project',()=>{current(c,args,signal);const result=Object.freeze(validateVisualResult({mime:'image/png',...size,data:bytes.toString('base64')}));releaseGuards.set(result,AsyncResource.bind(()=>current(c,args,signal)));return result;});}catch{refused();}finally{bytes?.fill?.(0);}};
}
module.exports={createAgentVisualExport,getVisualReleaseGuard,validateVisualResult,pngSize,MAX_BYTES,MAX_DIMENSION,MAX_PIXELS};