Add inspected agent viewport export with policy-bound final release
This commit is contained in:
1 parent
c8b6ff9cdd
commit
e7d778aeac
24 files changed
+483
-32
No files matched your search
@@ -4,7 +4,7 @@ const crypto = require('node:crypto');
|
||||
const channel=require('../../packages/core/policy-channel.cjs');
|
||||
|
||||
const APP_ACTIONS=Object.freeze(['vault:prepare-submit','sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']);
|
||||
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
|
||||
const SCOPES = Object.freeze({ screenshot:'browser:screenshot', sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
|
||||
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
const bounded = (value, max, name) => {
|
||||
if (typeof value !== 'string' || !value.length || value.length > max || /\u0000/.test(value)) throw fail(400, `Invalid ${name}`);
|
||||
@@ -16,7 +16,7 @@ function validate(body) {
|
||||
if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action');
|
||||
const args = body.args ?? {};
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments');
|
||||
const keys = { 'sessions:save':['name','sessionIds','id','expectedRevision'],'sessions:list':[],'sessions:restore':['id','planId'],'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'vault:prepare-submit':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
|
||||
const keys = { 'sessions:save':['name','sessionIds','id','expectedRevision'],'sessions:list':[],'sessions:restore':['id','planId'],'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'vault:prepare-submit':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],screenshot:['sessionId'], sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
|
||||
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
|
||||
const clean = {};
|
||||
if(action==='sessions:save'){
|
||||
@@ -70,6 +70,7 @@ function validate(body) {
|
||||
}
|
||||
// Never return adapter objects wholesale: session handles and sanitized text only.
|
||||
function project(action, result) {
|
||||
if(action==='screenshot')return require('./agent-visual-export.cjs').validateVisualResult(result);
|
||||
if(action.startsWith('sessions:')){
|
||||
const handle=value=>{if(typeof value!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value))throw fail(502,'Invalid session handle');return value;};
|
||||
if(action==='sessions:save')return {id:handle(result?.id),revision:handle(result?.revision),tabCount:sessionCount(result?.tabCount)};
|
||||
@@ -125,10 +126,12 @@ function project(action, result) {
|
||||
}
|
||||
function sessionCount(value){if(!Number.isSafeInteger(value)||value<1||value>32)throw fail(502,'Invalid session count');return value;}
|
||||
|
||||
async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, traceOperation = async (_name,fn)=>fn(), timeoutMs = 10000, maxRequestsPerMinute = 60, principalMode = 'legacy', principals = [] } = {}) {
|
||||
async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, traceOperation = async (_name,fn)=>fn(), traceRelease = (_name,fn)=>fn(), timeoutMs = 10000, actionTimeouts = {}, maxRequestsPerMinute = 60, principalMode = 'legacy', principals = [] } = {}) {
|
||||
if(credentialFile!==undefined)throw new TypeError('Plaintext pairing files are prohibited; use protected persistCredential');
|
||||
if (typeof authorize !== 'function' || typeof audit !== 'function' || !actions) throw new TypeError('authorize, actions and audit are required');
|
||||
if (typeof authorize !== 'function' || typeof audit !== 'function' || typeof traceRelease!=='function' || !actions) throw new TypeError('authorize, actions and audit are required');
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 60000 || !Number.isInteger(maxRequestsPerMinute) || maxRequestsPerMinute < 1 || maxRequestsPerMinute > 1000) throw new TypeError('Invalid limits');
|
||||
if(!actionTimeouts||typeof actionTimeouts!=='object'||Array.isArray(actionTimeouts)||Object.entries(actionTimeouts).some(([action,limit])=>!Object.hasOwn(SCOPES,action)||!Number.isInteger(limit)||limit<10||limit>60000))throw TypeError('Invalid action deadlines');
|
||||
const deadlines=Object.freeze({...actionTimeouts});
|
||||
if(!['legacy','individual'].includes(principalMode))throw new TypeError('Invalid principal mode');
|
||||
const token = principalMode==='legacy'?crypto.randomBytes(32).toString('base64url'):undefined;
|
||||
const serviceId=crypto.randomUUID(), principalRecords=new Map(), approved=new Map(), owners=new Map(), generationFloor=new Map();
|
||||
@@ -155,7 +158,7 @@ async function startAgentControl({ authorize, actions, audit, credentialFile, pe
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
const reply = (status, data) => { if (!res.destroyed && !res.writableEnded) { res.writeHead(status); res.end(JSON.stringify(data)); } };
|
||||
const controller = new AbortController();
|
||||
let principal, verifiedPrincipal, action, outcome = 'denied', timer, protectedContext, bundle, validatedRequest;
|
||||
let principal, verifiedPrincipal, action, outcome = 'denied', timer, protectedContext, bundle, validatedRequest, releaseGuard;
|
||||
try {
|
||||
let proof;
|
||||
if(principalMode==='individual'){
|
||||
@@ -183,23 +186,23 @@ async function startAgentControl({ authorize, actions, audit, credentialFile, pe
|
||||
if (typeof actions[action] !== 'function') throw fail(403, 'Action unavailable');
|
||||
running.add(controller);if(principal)owners.set(controller,principal.id);
|
||||
// Authorizers must be side-effect free: individual grants are checked at each release boundary.
|
||||
const reauthorize=async()=>{if(!principal)return;const grant=await traceOperation('agent.authorize',()=>authorize({...request,principalId:principal.id,generation:principal.generation,signal:controller.signal}));if(!enabled||!principalLive(principal)||controller.signal.aborted||grant?.allowed!==true||!['agent','admin'].includes(grant.role)||!Array.isArray(grant.scopes)||!grant.scopes.includes(request.scope))throw fail(403,'Policy denied action');};
|
||||
const reauthorize=async()=>{if(!principal&&action!=='screenshot')return;const grant=await traceOperation('agent.authorize',()=>authorize({...request,...(principal?{principalId:principal.id,generation:principal.generation}:{}),signal:controller.signal}));if(!enabled||(principal&&!principalLive(principal))||controller.signal.aborted||grant?.allowed!==true||!['agent','admin'].includes(grant.role)||!Array.isArray(grant.scopes)||!grant.scopes.includes(request.scope))throw fail(403,'Policy denied action');};
|
||||
const operation = (async () => {
|
||||
const grant = await traceOperation('agent.authorize',()=>authorize({ ...request,...(principal?{principalId:principal.id,generation:principal.generation}:{}), signal: controller.signal }));
|
||||
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted || grant?.allowed !== true || !['agent', 'admin'].includes(grant.role) || !Array.isArray(grant.scopes) || !grant.scopes.includes(request.scope)) throw fail(403, 'Policy denied action');
|
||||
await traceOperation('agent.audit',()=>audit({ ...(principal?{principalId:principal.id,generation:principal.generation}:{}),action, scope: request.scope, sessionId: request.sessionId, outcome: 'authorized' }));
|
||||
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
await reauthorize();
|
||||
const value=await traceOperation('agent.dispatch',()=>actions[action](request.args, { signal: controller.signal,...(verifiedPrincipal?{principalId:verifiedPrincipal.id,generation:verifiedPrincipal.generation}:{}) }));return traceOperation('agent.project',()=>project(action,value));
|
||||
const value=await traceOperation('agent.dispatch',()=>actions[action](request.args, { signal: controller.signal,...(verifiedPrincipal?{principalId:verifiedPrincipal.id,generation:verifiedPrincipal.generation}:{}) }));if(action==='screenshot'){releaseGuard=require('./agent-visual-export.cjs').getVisualReleaseGuard(value);if(typeof releaseGuard!=='function')throw fail(403,'Visual release context unavailable');releaseGuard();}return traceOperation('agent.project',()=>project(action,value));
|
||||
})();
|
||||
const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, timeoutMs); })]);
|
||||
const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, deadlines[action]??timeoutMs); })]);
|
||||
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
await reauthorize();
|
||||
outcome = 'completed';
|
||||
await traceOperation('agent.audit',()=>audit({ ...(principal?{principalId:principal.id,generation:principal.generation}:{}),action, outcome,scope:request.scope,sessionId:request.sessionId }));
|
||||
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
await reauthorize();
|
||||
const encryptedReply=await traceOperation('agent.encrypt',()=>channel.response({kind:'agent-response',status:200,result},bundle,protectedContext));await reauthorize();if(!enabled||(principal&&!principalLive(principal))||controller.signal.aborted)throw fail(403,'Agent access revoked');reply(200,encryptedReply);
|
||||
const encryptedReply=await traceOperation('agent.encrypt',()=>channel.response({kind:'agent-response',status:200,result},bundle,protectedContext));await reauthorize();if(!enabled||(principal&&!principalLive(principal))||controller.signal.aborted)throw fail(403,'Agent access revoked');if(action==='screenshot'){if(typeof releaseGuard!=='function')throw fail(403,'Visual release context unavailable');let called=false,refused;const once=()=>{if(!called){called=true;try{releaseGuard();}catch(error){refused=error;}}if(refused)throw refused;};try{const observation=traceRelease('visual.release',once);observation?.catch?.(()=>{});}catch{}if(!called)once();if(refused)throw fail(403,'Visual release context changed');}reply(200,encryptedReply);
|
||||
} catch (error) {
|
||||
outcome=(error.status||500)>=500?'failed':'denied';
|
||||
// Diagnostics expose only known static reasons, never arbitrary adapter errors.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
'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};
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
|
||||
// Runs only in a private isolated world. No bridge, page callback or caller code.
|
||||
function visualDocumentProbe(expectedURL) {
|
||||
if (location.href !== expectedURL || document.readyState !== 'complete') throw Error('Visual probe refused');
|
||||
const key='__safetynetVisualProbeV1';
|
||||
let state=globalThis[key];
|
||||
if (!state) {
|
||||
state={id:crypto.randomUUID(),revision:0,roots:new WeakSet(),watches:[]};
|
||||
const changed=()=>{state.revision++;};
|
||||
state.watch=root=>{
|
||||
if(state.roots.has(root))return;
|
||||
if(state.watches.length>=129)throw Error('Visual probe refused');
|
||||
const observer=new MutationObserver(changed);
|
||||
observer.observe(root,{subtree:true,childList:true,characterData:true,attributes:true});
|
||||
state.roots.add(root);state.watches.push({root,observer});
|
||||
if(root!==document)changed();
|
||||
};
|
||||
state.watch(document);
|
||||
for (const name of ['input','change','scroll','resize']) addEventListener(name,changed,true);
|
||||
Object.defineProperty(globalThis,key,{value:state,writable:false,configurable:false});
|
||||
}
|
||||
for(const watch of [...state.watches]){
|
||||
if(watch.root!==document&&watch.root.host?.isConnected===false){watch.observer.disconnect();state.roots.delete(watch.root);state.watches.splice(state.watches.indexOf(watch),1);state.revision++;}
|
||||
else if(watch.observer.takeRecords().length)state.revision++;
|
||||
}
|
||||
const forbidden=/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|credit.?card|cc-number|cc-csc/i;
|
||||
let safe=true,visited=0;
|
||||
const visible=e=>{
|
||||
for(let n=e;n;n=n.parentElement){const s=getComputedStyle(n);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0'||n.hidden)return false;}
|
||||
const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&r.bottom>0&&r.right>0&&r.left<innerWidth&&r.top<innerHeight;
|
||||
};
|
||||
function walk(root) {
|
||||
state.watch(root);
|
||||
for(const e of root.querySelectorAll('*')) {
|
||||
if(++visited>20000){safe=false;return;}
|
||||
if(['IFRAME','FRAME','OBJECT','EMBED'].includes(e.tagName)){safe=false;return;}
|
||||
// Credential-bearing controls are refused even offscreen: a transient CSS
|
||||
// reveal must not evade the checks surrounding native surface capture.
|
||||
if(/^(INPUT|TEXTAREA|SELECT)$/.test(e.tagName)&&
|
||||
(e.type==='password'||['name','id','autocomplete','aria-label'].some(a=>forbidden.test(e.getAttribute(a)||'')))){safe=false;return;}
|
||||
if(e.hasAttribute('data-sensitive')&&visible(e)){safe=false;return;}
|
||||
if(e.shadowRoot){walk(e.shadowRoot);if(!safe)return;}
|
||||
}
|
||||
}
|
||||
walk(document);
|
||||
const width=Math.round(innerWidth),height=Math.round(innerHeight);
|
||||
// Active media/animations are not represented by the DOM revision counter.
|
||||
if(state.watches.some(w=>(w.root.getAnimations?.()||[]).some(a=>a.playState==='running'))||
|
||||
[...document.querySelectorAll('video')].some(e=>visible(e)&&!e.paused))safe=false;
|
||||
return {safe,revision:[state.id,state.revision,width,height,scrollX,scrollY,devicePixelRatio,visualViewport?.scale||1].join(':'),width,height};
|
||||
}
|
||||
|
||||
async function probeVisualContext(context) {
|
||||
const contents=context.webContents,frame=contents.mainFrame;
|
||||
if(!frame||frame.isDestroyed()||frame.framesInSubtree.length!==1||contents.isLoading())throw Error('Managed visual export refused');
|
||||
await require('./page-clipboard-guard.cjs').guardedVisualStructure(contents);
|
||||
const result=await contents.executeJavaScriptInIsolatedWorld(1004,[{code:`(${visualDocumentProbe.toString()})(${JSON.stringify(context.url)})`}],false);
|
||||
if(contents.isDestroyed()||contents.mainFrame!==frame||frame.isDestroyed()||frame.framesInSubtree.length!==1)throw Error('Managed visual export refused');
|
||||
return result;
|
||||
}
|
||||
module.exports={probeVisualContext,visualDocumentProbe};
|
||||
@@ -12,6 +12,7 @@ const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http');
|
||||
const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=require('@opentelemetry/api');
|
||||
const names=new Set(['browser.window','browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']);
|
||||
const operations=new Set(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']);
|
||||
for(const operation of ['visual.probe','visual.capture','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.validate','visual.project','visual.release'])operations.add(operation);
|
||||
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
|
||||
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
|
||||
names.add('browser.vault');names.add('browser.credential');
|
||||
|
||||
@@ -417,13 +417,21 @@ async function ensureAgentAPI(){
|
||||
const actor=agentActorStorage.getStore();await auditAgent({action:'click',outcome:'completed',sessionId:metadata.sessionId,...(actor?.principalId?{principalId:actor.principalId,generation:actor.generation}:{}),protectedInteraction:payload});
|
||||
}});
|
||||
Object.assign(actions,agentSessionControls());
|
||||
actions.screenshot=require('./agent-visual-export.cjs').createAgentVisualExport({
|
||||
getContext:id=>{const c=agentContext(id),actor=agentActorStorage.getStore();return {...c,ready:c.ready&&actorGrantAllowed(actor,'screenshot'),actor:c.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};},contextCurrent:currentTabContext,
|
||||
probeSensitive:require('./agent-visual-probe.cjs').probeVisualContext,capture:c=>capturePNG(c.webContents),
|
||||
inspectLocal:async(bytes,p)=>{if(!localInspector)throw Error('Managed visual export refused');return diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,p));},
|
||||
inspectEnterprise:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,p,'upload')),audit:auditAgent,
|
||||
traceOperation:(operation,fn)=>diagnosticSpan('browser.visual',{operation},fn)
|
||||
});
|
||||
for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};}
|
||||
for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{
|
||||
if(context.principalId&&['navigate','back','forward','reload','history:go','vault:fill','vault:prepare-submit','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
|
||||
return original(args,context);
|
||||
}));}
|
||||
agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[],
|
||||
agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,actionTimeouts:{screenshot:45000},principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[],
|
||||
traceOperation:(operation,fn)=>diagnosticSpan('browser.agent',{operation},fn),
|
||||
traceRelease:(operation,fn)=>diagnostics.spanSync('browser.visual',diagnosticContext({operation}),fn),
|
||||
onError:error=>{if(!app.isPackaged&&process.env.SAFETYNET_SMOKE_ARTIFACTS)fs.appendFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'smoke-entry.txt'),`Agent action diagnostic: ${error.reason}\n`);},
|
||||
authorize:({action,args,scope,principalId,generation})=>{
|
||||
const actor={principalId,generation,action};
|
||||
|
||||
@@ -52,4 +52,23 @@ function disposePageClipboardGuard(contents){
|
||||
owners.delete(contents);owner.healthy=false;owner.api.removeListener('detach',owner.detached);
|
||||
if(owner.api.isAttached())owner.api.detach();
|
||||
}
|
||||
module.exports={attachPageClipboardGuard,guardedScreenshot,disposePageClipboardGuard};
|
||||
async function guardedVisualStructure(contents){
|
||||
const owner=owners.get(contents);
|
||||
if(!owner?.healthy||!owner.api.isAttached()||contents.isDestroyed())throw Error('Managed visual export refused');
|
||||
// Native DOM data detects closed/declarative roots that isolated DOM queries
|
||||
// cannot see. It is consumed privately and never logged or returned to agents.
|
||||
const result=await deadline(owner.api.sendCommand('DOM.getDocument',{depth:-1,pierce:true}));
|
||||
if(owners.get(contents)!==owner||!owner.healthy||!owner.api.isAttached())throw Error('Managed visual export refused');
|
||||
const queue=[result?.root];let nodes=0,characters=0;
|
||||
while(queue.length){
|
||||
const node=queue.pop();if(!node||++nodes>20000)throw Error('Managed visual export refused');
|
||||
for(const text of [node.nodeName,node.nodeValue,...(node.attributes||[])]){if(typeof text!=='string'||(characters+=text.length)>1024*1024)throw Error('Managed visual export refused');}
|
||||
const shadows=node.shadowRoots||[],children=node.children||[];
|
||||
if(!Array.isArray(shadows)||!Array.isArray(children)||nodes+queue.length+shadows.length+children.length>20000)throw Error('Managed visual export refused');
|
||||
for(const root of shadows){if(root.shadowRootType==='closed')throw Error('Managed visual export refused');queue.push(root);}
|
||||
if(node.contentDocument)throw Error('Managed visual export refused');
|
||||
queue.push(...children);
|
||||
}
|
||||
return {safe:true,nodes};
|
||||
}
|
||||
module.exports={attachPageClipboardGuard,guardedScreenshot,guardedVisualStructure,disposePageClipboardGuard};
|
||||
Reference in new issue
Block a user