Capture guarded human gestures and verify agent administration
This commit is contained in:
1 parent
244f01630b
commit
adf0940e5f
32 files changed
+523
-52
No files matched your search
@@ -9,9 +9,9 @@ const {LoggerProvider,BatchLogRecordProcessor}=require('@opentelemetry/sdk-logs'
|
||||
const {resourceFromAttributes}=require('@opentelemetry/resources');
|
||||
const {OTLPTraceExporter}=require('@opentelemetry/exporter-trace-otlp-http');
|
||||
const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http');
|
||||
const {trace,ROOT_CONTEXT,SpanStatusCode}=require('@opentelemetry/api');
|
||||
const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=require('@opentelemetry/api');
|
||||
const names=new Set(['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(['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']);
|
||||
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']);
|
||||
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
|
||||
const decisions=new Set(['allow','block','deny','unknown']),statuses=new Set(['ok','error','cancelled','timeout']);
|
||||
const requestTypes=new Set(['mainFrame','subFrame','stylesheet','script','image','font','object','xhr','ping','cspReport','media','webSocket','other']);
|
||||
@@ -61,8 +61,26 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
|
||||
async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
|
||||
activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
|
||||
}
|
||||
// Durable events may be delivered by a flusher started under another event's
|
||||
// async context. Adopt only data-property IDs; never inherit that unrelated
|
||||
// parent, invoke caller getters, or allow diagnostic failure to repeat work.
|
||||
function withCorrelation(ids,fn){
|
||||
if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');
|
||||
const current=generation;let parent=null;
|
||||
try{
|
||||
if(current?.active&&ids&&typeof ids==='object'&&!Array.isArray(ids)&&[Object.prototype,null].includes(Object.getPrototypeOf(ids))){
|
||||
const descriptors=Object.getOwnPropertyDescriptors(ids),traceId=descriptors.traceId?.value,spanId=descriptors.spanId?.value;
|
||||
if(typeof traceId==='string'&&/^[a-f0-9]{32}$/.test(traceId)&&traceId!=='0'.repeat(32)&&typeof spanId==='string'&&/^[a-f0-9]{16}$/.test(spanId)&&spanId!=='0'.repeat(16)){
|
||||
const rate=current.config.mode==='debug'?current.config.debugSampleRate:current.config.sampleRate;
|
||||
const sampled=randomInt(1000000)<Math.floor(rate*1000000);
|
||||
parent={generation:current,span:trace.wrapSpanContext({traceId,spanId,traceFlags:sampled?TraceFlags.SAMPLED:TraceFlags.NONE,isRemote:false})};
|
||||
}
|
||||
}
|
||||
}catch{/* Invalid diagnostic metadata cannot affect protected delivery. */}
|
||||
return storage.run(parent,fn);
|
||||
}
|
||||
async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);}
|
||||
async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);}
|
||||
return {configure,span,event,importStages,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
|
||||
return {configure,span,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
|
||||
}
|
||||
module.exports={createDiagnostics,attributes,endpointAllowed};
|
||||
@@ -25,7 +25,7 @@ function validateInteractionEvidence(value){
|
||||
try{const u=new URL(value.pageUrl);return !credentialPatterns.some(p=>p.test(value.pageUrl)||p.test(decodeURIComponent(u.pathname)));}catch{return false;}
|
||||
}
|
||||
function buildInteractionEvidence({action,sessionId,url,target,sensitiveField,outcome,bytes,captureSource},policy){
|
||||
if(policy?.evidence?.enabled!==true||policy.evidence.captureInteractions!==true||sensitiveField!==false||outcome!=='completed')return null;
|
||||
if(policy?.evidence?.enabled!==true||policy.evidence.captureInteractions!==true||sensitiveField!==false||!['completed','observed'].includes(outcome))return null;
|
||||
let page;try{page=new URL(url);if(!['http:','https:'].includes(page.protocol))return null;}catch{return null;}
|
||||
if(target!==undefined){
|
||||
if(!target||typeof target!=='object'||Array.isArray(target)||Object.keys(target).some(key=>!['tag','role','x','y'].includes(key))||typeof target.tag!=='string')return null;
|
||||
@@ -35,7 +35,7 @@ function buildInteractionEvidence({action,sessionId,url,target,sensitiveField,ou
|
||||
}
|
||||
// Paths can carry identifiers or credentials. Automatic metadata uses origin
|
||||
// only; detailed evidence remains an explicitly governed separate workflow.
|
||||
const payload={pageUrl:page.origin+'/',action,sessionId,outcome:'completed',sensitiveField:false,captureSource,...(target===undefined?{}:{target}),...(bytes===undefined?{}:{bytes})};
|
||||
const payload={pageUrl:page.origin+'/',action,sessionId,outcome,sensitiveField:false,captureSource,...(target===undefined?{}:{target}),...(bytes===undefined?{}:{bytes})};
|
||||
return validateInteractionEvidence(payload)?structuredClone(payload):null;
|
||||
}
|
||||
module.exports={buildDownloadEvidence,validateDownloadEvidence,buildInteractionEvidence,validateInteractionEvidence};
|
||||
@@ -13,7 +13,7 @@ async function deliverAuditRecord({record,send,getContext}){
|
||||
const current=state();if(current==='discard')return {eventDelivered:true,evidenceDiscarded:true};if(current==='pause')throw Error('Evidence delivery paused until managed context is ready');
|
||||
const payload=validatedEvidence(protectedEvidence.payload,type);
|
||||
if(type==='interaction'){
|
||||
const expectedKind=payload.captureSource==='guarded-clipboard'?`clipboard.${payload.action}`:'agent.action';
|
||||
const expectedKind=payload.captureSource==='guarded-clipboard'?`clipboard.${payload.action}`:payload.captureSource==='human-observer'?'browser.interaction':'agent.action';
|
||||
if(event.kind!==expectedKind||payload.captureSource==='guarded-clipboard'&&!['copy','cut','paste'].includes(payload.action)||payload.captureSource==='agent-adapter'&&payload.action!=='click')throw Error('Invalid protected evidence source');
|
||||
}
|
||||
try{const result=await send('/api/device/evidence',{eventId:event.eventId,type,payload});
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
// Runs only in the privileged isolated world; no preload IPC or page-world bridge.
|
||||
function humanInteractionDOM(mode,url,token){
|
||||
const key='__safetynetHumanInteraction';
|
||||
const previous=globalThis[key];
|
||||
if(mode==='dispose'){if(previous?.token===token){previous.dispose();delete globalThis[key];}return null;}
|
||||
if(window.top!==window||location.href!==url)return null;
|
||||
if(mode==='drain'){
|
||||
if(!previous||previous.token!==token)return null;
|
||||
previous.until=Date.now()+1500;const events=previous.queue.splice(0,16);return {events,dropped:previous.dropped};
|
||||
}
|
||||
if(mode!=='install')return null;
|
||||
if(previous){previous.dispose();delete globalThis[key];}
|
||||
const state={token,queue:[],dropped:0,until:Date.now()+1500,tokens:20,last:Date.now(),gesture:null};
|
||||
const sensitive=e=>{
|
||||
let steps=0;
|
||||
for(let p=e;p;p=p.parentElement){
|
||||
if(++steps>64)return true;
|
||||
if(p.hasAttribute?.('data-sensitive')||/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master|credit.?card|cc-number|cc-csc/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute?.(a)||'').join(' '))||/^(password|file|hidden)$/i.test(p.type||''))return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const formSensitive=e=>{
|
||||
const form=e?.tagName==='FORM'?e:e?.form||e?.closest?.('form');
|
||||
if(!form)return false;
|
||||
const controls=form.elements;if(!controls||controls.length>128)return true;
|
||||
for(let i=0;i<controls.length;i++)if(sensitive(controls[i]))return true;
|
||||
return false;
|
||||
};
|
||||
const record=event=>{
|
||||
const now=Date.now();if(event.isTrusted!==true||now>state.until||location.href!==url||event.type==='submit'&&(navigator.userActivation?.isActive!==true||state.gesture===null||now-state.gesture>1000))return;
|
||||
const target=event.target?.nodeType===1?event.target:event.target?.parentElement;
|
||||
if(!target?.isConnected||sensitive(target)||formSensitive(target))return;
|
||||
if(event.type==='click')state.gesture=now;
|
||||
state.tokens=Math.min(20,state.tokens+Math.max(0,now-state.last)*0.01);state.last=now;
|
||||
if(state.tokens<1||state.queue.length>=16){state.dropped=Math.min(1000000,state.dropped+1);return;}
|
||||
state.tokens--;
|
||||
const tag=['A','BUTTON','INPUT','TEXTAREA','SELECT','DIV','SPAN','P','BODY'].includes(target.tagName)?target.tagName:'OTHER';
|
||||
const facts={tag};const role=target.getAttribute?.('role');
|
||||
if(['link','button','checkbox','radio','textbox','combobox','text','other','menuitem','option'].includes(role))facts.role=role;
|
||||
if(event.type==='click'&&Number.isFinite(event.clientX)&&Number.isFinite(event.clientY)&&event.clientX>=0&&event.clientY>=0&&event.clientX<=innerWidth&&event.clientY<=innerHeight){facts.x=Math.round(event.clientX);facts.y=Math.round(event.clientY);}
|
||||
state.queue.push({action:event.type,outcome:'observed',sensitiveField:false,target:facts});
|
||||
};
|
||||
const enter=event=>{if(event.isTrusted===true&&event.key==='Enter'&&Date.now()<=state.until&&location.href===url&&!sensitive(event.target)&&!formSensitive(event.target))state.gesture=Date.now();};
|
||||
window.addEventListener('click',record,true);window.addEventListener('submit',record,true);window.addEventListener('keydown',enter,true);
|
||||
state.dispose=()=>{window.removeEventListener('click',record,true);window.removeEventListener('submit',record,true);window.removeEventListener('keydown',enter,true);state.queue.length=0;};
|
||||
globalThis[key]=state;return {installed:true};
|
||||
}
|
||||
function humanInteractionScript(mode,{url,token}){
|
||||
if(!['install','drain','dispose'].includes(mode)||typeof url!=='string'||url.length>4096||typeof token!=='string'||!/^[a-f0-9]{32,64}$/.test(token))throw TypeError('Invalid interaction observer context');
|
||||
return `(${humanInteractionDOM.toString()})(${JSON.stringify(mode)},${JSON.stringify(url)},${JSON.stringify(token)})`;
|
||||
}
|
||||
module.exports={humanInteractionScript};
|
||||
|
||||
function createHumanInteractionObserver({getContext,isCurrent,executeScript,onObserved,onDropped=()=>{},interval=250,deadline=1000}){
|
||||
if([getContext,isCurrent,executeScript,onObserved,onDropped].some(fn=>typeof fn!=='function'))throw TypeError('Invalid interaction observer callbacks');
|
||||
if(!Number.isSafeInteger(interval)||interval<100||interval>500||!Number.isSafeInteger(deadline)||deadline<100||deadline>1000)throw TypeError('Invalid interaction observer timing');
|
||||
const {randomBytes}=require('node:crypto');let current=null,retired=null,busy=false,closed=false,epoch=0;
|
||||
const eligible=c=>!!c&&c.ready===true&&c.enabled===true&&c.foreground===true&&typeof c.sessionId==='string'&&typeof c.url==='string'&&typeof c.token==='string'&&Number.isSafeInteger(c.policyVersion)&&c.webContents&&!c.webContents.isDestroyed?.()&&/^https?:\/\//.test(c.url)&&isCurrent(c);
|
||||
const same=(a,b)=>a&&b&&a.sessionId===b.sessionId&&a.url===b.url&&a.token===b.token&&a.policyVersion===b.policyVersion&&a.webContents===b.webContents;
|
||||
const validBatch=result=>result&&typeof result==='object'&&!Array.isArray(result)&&Object.keys(result).every(k=>['events','dropped'].includes(k))&&Array.isArray(result.events)&&result.events.length<=16&&Number.isSafeInteger(result.dropped)&&result.dropped>=0&&result.dropped<=1000000;
|
||||
async function tick(){
|
||||
if(closed||busy)return;busy=true;const generation=epoch;let timedOut=false;const timer=setTimeout(()=>{timedOut=true;epoch++;current=null;},deadline);timer.unref?.();
|
||||
try{
|
||||
if(retired){const previous=retired;retired=null;await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');if(closed||timedOut||generation!==epoch)return;}
|
||||
const context=getContext();
|
||||
if(!eligible(context)){const previous=current;current=null;if(previous)await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');return;}
|
||||
if(!same(current?.context,context)){
|
||||
const previous=current;current=null;if(previous)await executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose');
|
||||
if(closed||timedOut||generation!==epoch||!eligible(context))return;
|
||||
const secret=randomBytes(24).toString('hex');const result=await executeScript(context.webContents,humanInteractionScript('install',{url:context.url,token:secret}),'install');
|
||||
if(!closed&&!timedOut&&generation===epoch&&eligible(context)&&result?.installed===true)current={context,secret,dropped:0};
|
||||
else if(result?.installed===true)await executeScript(context.webContents,humanInteractionScript('dispose',{url:context.url,token:secret}),'dispose');return;
|
||||
}
|
||||
const selected=current;const result=await executeScript(context.webContents,humanInteractionScript('drain',{url:context.url,token:selected.secret}),'drain');
|
||||
if(closed||timedOut||generation!==epoch||current!==selected||!eligible(context)||!validBatch(result))return;
|
||||
if(result.dropped>selected.dropped){await onDropped(result.dropped-selected.dropped,context);selected.dropped=result.dropped;}
|
||||
for(const event of result.events){
|
||||
if(closed||timedOut||generation!==epoch||current!==selected||!eligible(context))return;
|
||||
// Never trust arbitrary renderer keys; the root validates the shared evidence schema too.
|
||||
if(!event||typeof event!=='object'||Array.isArray(event)||Object.keys(event).some(k=>!['action','outcome','sensitiveField','target'].includes(k))||!['click','submit'].includes(event.action)||event.outcome!=='observed'||event.sensitiveField!==false)continue;
|
||||
await onObserved(event,context);
|
||||
}
|
||||
}catch{current=null;}finally{clearTimeout(timer);busy=false;}
|
||||
}
|
||||
const timer=setInterval(()=>{void tick();},interval);timer.unref?.();
|
||||
function refresh(){epoch++;if(current)retired=current;current=null;void tick();}
|
||||
function dispose(){if(closed)return;closed=true;epoch++;clearInterval(timer);const previous=current||retired;current=null;retired=null;if(previous)Promise.resolve().then(()=>executeScript(previous.context.webContents,humanInteractionScript('dispose',{url:previous.context.url,token:previous.secret}),'dispose')).catch(()=>{});}
|
||||
return {refresh,dispose,poll:tick};
|
||||
}
|
||||
module.exports.createHumanInteractionObserver=createHumanInteractionObserver;
|
||||
+27
-3
@@ -203,7 +203,7 @@ function enforceEvidencePolicy(){
|
||||
|
||||
async function flushAudit(){
|
||||
if(!device||flushing||!auditQueue.length)return;flushing=true;
|
||||
try{for(let n=0;n<100&&auditQueue.length;n++){await require('./evidence-delivery.cjs').deliverAuditRecord({record:auditQueue[0],send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})});auditQueue.shift();persistAudit();}}
|
||||
try{for(let n=0;n<100&&auditQueue.length;n++){const record=auditQueue[0];await diagnostics.withCorrelation(record,()=>diagnosticSpan('browser.management',{operation:'audit.deliver'},()=>require('./evidence-delivery.cjs').deliverAuditRecord({record,send:(route,body)=>request(device.server,route,body,device.token),getContext:()=>({ready,policy,deviceId:device.id})})));auditQueue.shift();persistAudit();}}
|
||||
catch{}finally{flushing=false;}
|
||||
}
|
||||
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts){
|
||||
@@ -567,15 +567,39 @@ function hooks(){
|
||||
});
|
||||
// Session request, header and permission gates are installed exactly once.
|
||||
}
|
||||
function interactionObserverContext(tab){
|
||||
const contents=tab.contents||tab.view.webContents,nativeContext=tabContext(tab.id);
|
||||
return {ready:ready&¤tTabContext(nativeContext),enabled:policy?.evidence?.enabled===true&&policy.evidence.captureInteractions===true,policyVersion:policy?.version||0,sessionId:tab.id,url:contents.getURL(),token:`${tab.security.interactionDocumentGeneration}:${nativeContext.activation}`,foreground:tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen,webContents:contents,nativeContext,documentGeneration:tab.security.interactionDocumentGeneration};
|
||||
}
|
||||
function currentInteractionObserverContext(context){
|
||||
const tab=activeTab();
|
||||
if(!context||!ready||!tab||policy?.evidence?.enabled!==true||policy.evidence.captureInteractions!==true||context.policyVersion!==policy.version||Date.parse(policy.expiresAt)<=Date.now()||context.sessionId!==tab.id||context.webContents!==tab.contents||tab.contents.isDestroyed()||context.url!==tab.contents.getURL()||context.documentGeneration!==tab.security.interactionDocumentGeneration||tab.startPage||overviewPanel||toolsOpen)return false;
|
||||
const live=tabs.context(tab.id);
|
||||
return !live.paused&&live.activation===context.nativeContext.activation;
|
||||
}
|
||||
function installSiteHooks(tab){
|
||||
const contents=tab.view.webContents,security=tab.security;
|
||||
security.interactionDocumentGeneration=0;
|
||||
security.interactionObserver=require('./human-interaction.cjs').createHumanInteractionObserver({
|
||||
getContext:()=>interactionObserverContext(tab),isCurrent:currentInteractionObserverContext,
|
||||
executeScript:(target,code,operation)=>diagnosticSpan('browser.evidence',{operation:`evidence.observer.${operation}`,sessionId:tab.id},()=>target.executeJavaScriptInIsolatedWorld(998,[{code}],false)),
|
||||
onObserved:(event,context)=>diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:context.sessionId},async()=>{
|
||||
if(!currentInteractionObserverContext(context))return;
|
||||
const payload=require('./evidence-capture.cjs').buildInteractionEvidence({...event,sessionId:context.sessionId,url:context.url,captureSource:'human-observer'},policy);
|
||||
if(!payload||!currentInteractionObserverContext(context))return;
|
||||
await audit('browser.interaction',undefined,new URL(payload.pageUrl).hostname,undefined,undefined,payload,undefined,{action:payload.action,sessionId:payload.sessionId,outcome:'observed'});
|
||||
}),
|
||||
onDropped:async(count,context)=>{if(currentInteractionObserverContext(context)){diagnostics.event('browser.evidence',diagnosticContext({operation:'evidence.capture',sessionId:context.sessionId,count,status:'error'}));await audit('browser.interaction.dropped','interaction-capture-bounded');}}
|
||||
});
|
||||
contents.on('did-finish-load',()=>security.interactionObserver?.refresh());
|
||||
contents.once('destroyed',()=>security.interactionObserver?.dispose());
|
||||
const developerTools=createDeveloperToolsGuard({contents,getContext:()=>({ready,foreground:tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,url:contents.getURL(),token:tabContext(tab.id)}),isCurrent:context=>currentTabContext(context.token),audit:kind=>audit(kind),createFrontend:()=>{const frontend=new BrowserWindow({show:false,width:1000,height:750,title:'S Browser · Privileged developer tools',webPreferences:{session:browserSession,nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});security.developerFrontend=frontend.webContents;frontend.webContents.setWindowOpenHandler(()=>({action:'deny'}));return frontend;}});
|
||||
security.developerToolsGuard=developerTools;
|
||||
contents.setWindowOpenHandler(({url})=>{createNativeTab({url,agentAuthority:tab.security.agentAuthority}).catch(e=>{message=e.message;emit();});return {action:'deny'};});
|
||||
contents.on('will-navigate',(event,url)=>{if(!ready||!core.destinationAllowed(url,policy)||(tab.security.agentAuthority&&!actorDestinationAllowed(url,tab.security.agentAuthority)))event.preventDefault();});
|
||||
contents.on('will-attach-webview',event=>event.preventDefault());
|
||||
contents.on('before-input-event',(event,input)=>{if(developerTools.handleInput(event,input))return;const approval=security.visualApproval;if(policy?.security.visualPhishing.enabled&&(!approval||approval.generation!==tab.navigationId||approval.version!==policy.version||approval.url!==contents.getURL())){event.preventDefault();checkVisual(tab).catch(()=>{});}});
|
||||
contents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame){tabs.navigation(tab.id);if(_url!=='about:blank')tab.startPage=false;}});
|
||||
contents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame){if(!_inPlace){security.interactionDocumentGeneration++;security.interactionObserver?.refresh();}tabs.navigation(tab.id);if(_url!=='about:blank')tab.startPage=false;}});
|
||||
contents.on('did-navigate',()=>emit());contents.on('did-navigate-in-page',()=>emit());
|
||||
contents.on('did-finish-load',()=>pageTheme(tab));contents.on('page-title-updated',()=>{tab.title=contents.getTitle().slice(0,200);if(tab===activeTab())pageTitle=tab.title;emit();});
|
||||
installClipboardControls(contents);
|
||||
@@ -643,7 +667,7 @@ app.whenReady().then(async()=>{
|
||||
Menu.setApplicationMenu(null);
|
||||
win=new BrowserWindow({width:1320,height:900,minWidth:800,minHeight:600,frame:false,roundedCorners:true,hasShadow:true,backgroundMaterial:'acrylic',backgroundColor:'#eef1f5',autoHideMenuBar:true,webPreferences:{preload:path.join(__dirname,'preload.cjs'),nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});
|
||||
win.removeMenu();
|
||||
tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>disposePageClipboardGuard(tab.view.webContents)});
|
||||
tabs=createTabRegistry({createView:()=>new WebContentsView({webPreferences:{session:browserSession,preload:path.join(__dirname,'site-preload.cjs'),nodeIntegration:false,nodeIntegrationInSubFrames:true,contextIsolation:true,sandbox:true,webSecurity:true,allowRunningInsecureContent:false}}),attach:v=>win.contentView.addChildView(v),detach:v=>win.contentView.removeChildView(v),dispose:tab=>{tab.security.interactionObserver?.dispose();disposePageClipboardGuard(tab.contents);}});
|
||||
const executeClipboard=(mode,args)=>args.webContents.executeJavaScriptInIsolatedWorld(999,[{code:clipboardScript(mode,args)}],false);
|
||||
clipboardGuard=createClipboardGuard({
|
||||
getContext:contents=>{const target=contents||view?.webContents,tab=target&&tabs.fromContents(target.id),site=!!tab,url=target?.getURL()||'',token=site?tabContext(tab.id):null;return {ready:ready&&target?.isFocused()&&(site?tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen&&core.destinationAllowed(url,policy):target===win.webContents&&url===uiUrl),policy,webContents:target,url,navigationId:site?tab.navigationId:0,tabId:tab?.id,activation:token?.activation};},
|
||||
|
||||
@@ -5,7 +5,7 @@ const {randomUUID}=require('node:crypto');
|
||||
function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{},maxTabs=32}={}) {
|
||||
if(typeof createView!=='function'||![attach,detach,dispose].every(fn=>typeof fn==='function')||!Number.isSafeInteger(maxTabs)||maxTabs<1||maxTabs>128)throw TypeError('Invalid native tab registry');
|
||||
const tabs=new Map(),byContents=new Map();let activeId=null,activation=0,policyVersion=0,paused=true,closed=false;
|
||||
const requireTab=id=>{const tab=tabs.get(id);if(!tab||tab.view.webContents.isDestroyed())throw Error('Unknown native tab');return tab;};
|
||||
const requireTab=id=>{const tab=tabs.get(id);if(!tab||tab.contents.isDestroyed())throw Error('Unknown native tab');return tab;};
|
||||
const invalidate=tab=>{tab.navigationId++;tab.security.visualApproval=null;tab.security.visualToken='';tab.security.visualRevision=-1;tab.security.visualScanner?.reset();};
|
||||
function create({group=''}={}) {
|
||||
if(closed||tabs.size>=maxTabs)throw Error('Native tab capacity reached');
|
||||
@@ -13,31 +13,32 @@ function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{
|
||||
const view=createView(),contents=view?.webContents;
|
||||
if(!contents||!Number.isSafeInteger(contents.id)||contents.isDestroyed()||byContents.has(contents.id))throw Error('Invalid native tab view');
|
||||
const tab={id:randomUUID(),view,group,navigationId:0,title:'New Tab',color:'#edf1f6',startPage:true,security:{visualApproval:null,visualToken:'',visualRevision:-1,visualPending:null,visualBlocked:null,contentBlocked:null,visualScanner:null}};
|
||||
Object.defineProperty(tab,'contents',{value:contents,enumerable:false,writable:false,configurable:false});
|
||||
view.setVisible(false);tabs.set(tab.id,tab);byContents.set(contents.id,tab);
|
||||
if(!activeId)activate(tab.id);return tab;
|
||||
}
|
||||
function activate(id) {
|
||||
const next=requireTab(id);if(id===activeId)return next;
|
||||
const previous=tabs.get(activeId);if(previous){previous.view.setVisible(false);detach(previous.view);}
|
||||
const previous=tabs.get(activeId);if(previous){if(!previous.contents.isDestroyed())previous.view.setVisible(false);detach(previous.view);}
|
||||
activeId=id;activation++;attach(next.view);next.view.setVisible(false);return next;
|
||||
}
|
||||
function remove(id) {
|
||||
const tab=requireTab(id),wasActive=id===activeId;invalidate(tab);tab.view.setVisible(false);
|
||||
tabs.delete(id);byContents.delete(tab.view.webContents.id);
|
||||
if(wasActive){detach(tab.view);activeId=null;activation++;}
|
||||
try{dispose(tab);}finally{tab.view.webContents.close({waitForBeforeUnload:false});}
|
||||
if(wasActive&&tabs.size)activate(tabs.keys().next().value);
|
||||
const tab=tabs.get(id);if(!tab)throw Error('Unknown native tab');const wasActive=id===activeId;invalidate(tab);if(!tab.contents.isDestroyed())tab.view.setVisible(false);
|
||||
tabs.delete(id);byContents.delete(tab.contents.id);
|
||||
if(wasActive){if(!tab.contents.isDestroyed())detach(tab.view);activeId=null;activation++;}
|
||||
try{dispose(tab);}finally{if(!tab.contents.isDestroyed())tab.contents.close({waitForBeforeUnload:false});}
|
||||
if(wasActive&&!closed){const next=[...tabs.values()].find(item=>!item.contents.isDestroyed());if(next)activate(next.id);}
|
||||
}
|
||||
function context(id=activeId,{foreground=true}={}) {
|
||||
const tab=requireTab(id);return Object.freeze({tabId:tab.id,webContents:tab.view.webContents,navigationId:tab.navigationId,url:tab.view.webContents.getURL(),policyVersion,activation:foreground?activation:null,foreground,paused});
|
||||
const tab=requireTab(id);return Object.freeze({tabId:tab.id,webContents:tab.contents,navigationId:tab.navigationId,url:tab.contents.getURL(),policyVersion,activation:foreground?activation:null,foreground,paused});
|
||||
}
|
||||
function current(token,{expectedURL=token?.url}={}) {
|
||||
const tab=token&&tabs.get(token.tabId);
|
||||
return !!tab&&!paused&&!token.paused&&!tab.view.webContents.isDestroyed()&&tab.view.webContents===token.webContents&&tab.navigationId===token.navigationId&&policyVersion===token.policyVersion&&tab.view.webContents.getURL()===expectedURL&&(!token.foreground||(activeId===tab.id&&activation===token.activation));
|
||||
return !!tab&&!paused&&!token.paused&&!tab.contents.isDestroyed()&&tab.contents===token.webContents&&tab.navigationId===token.navigationId&&policyVersion===token.policyVersion&&tab.contents.getURL()===expectedURL&&(!token.foreground||(activeId===tab.id&&activation===token.activation));
|
||||
}
|
||||
function quarantine() {
|
||||
paused=true;activation++;
|
||||
for(const tab of tabs.values()){invalidate(tab);tab.view.webContents.stop();tab.view.setVisible(false);tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools?.();}
|
||||
for(const tab of tabs.values()){invalidate(tab);if(!tab.contents.isDestroyed()){tab.contents.stop();tab.view.setVisible(false);tab.security.developerToolsGuard?.close();tab.contents.closeDevTools?.();}}
|
||||
}
|
||||
function resume(version) {
|
||||
if(!Number.isSafeInteger(version)||version<1||version<policyVersion)throw Error('Invalid tab policy generation');
|
||||
@@ -49,6 +50,6 @@ function createTabRegistry({createView,attach=()=>{},detach=()=>{},dispose=()=>{
|
||||
fromContents:id=>byContents.get(id)||null,all:()=>[...tabs.values()],
|
||||
navigation:id=>invalidate(requireTab(id)),
|
||||
close:()=>{closed=true;quarantine();for(const id of [...tabs.keys()])remove(id);},
|
||||
summary:()=>({activeTabId:activeId,tabs:[...tabs.values()].map(tab=>({id:tab.id,group:tab.group,title:tab.title,url:tab.view.webContents.getURL(),startPage:tab.startPage,color:tab.color,canGoBack:tab.view.webContents.navigationHistory?.canGoBack()||false,canGoForward:tab.view.webContents.navigationHistory?.canGoForward()||false}))})};
|
||||
summary:()=>({activeTabId:activeId,tabs:[...tabs.values()].map(tab=>({id:tab.id,group:tab.group,title:tab.title,url:tab.contents.getURL(),startPage:tab.startPage,color:tab.color,canGoBack:tab.contents.navigationHistory?.canGoBack()||false,canGoForward:tab.contents.navigationHistory?.canGoForward()||false}))})};
|
||||
}
|
||||
module.exports={createTabRegistry};
|
||||
Reference in new issue
Block a user