Add protected interaction evidence and measured debugging phases

This commit is contained in:
Karl committed 2026-10-13 18:04:02 +02:00
1 parent ea448de2d4
commit 244f01630b
42 files changed
+640 -87

No files matched your search

+24 -3
View File
@@ -53,7 +53,10 @@ function domAction(mode, selector, text, expectedUrl) {
}
if (mode === 'click') {
if (!/^(A|BUTTON|INPUT)$/.test(e.tagName) || (e.tagName === 'INPUT' && !/^(button|submit|checkbox|radio)$/i.test(e.type))) throw Error('Target cannot be clicked');
e.click(); return {completed:true};
const rect=e.getBoundingClientRect(),target={tag:e.tagName.toLowerCase()},role=e.getAttribute('role');
if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(role))target.role=role;
const x=rect.left+rect.width/2,y=rect.top+rect.height/2;if(Number.isFinite(x)&&Number.isFinite(y)&&x>=0&&y>=0&&x<=innerWidth&&y<=innerHeight){target.x=Math.round(x);target.y=Math.round(y);}
e.click(); return {completed:true,target};
}
if (mode === 'type') {
if (e.readOnly || !(e.tagName === 'TEXTAREA' || (e.tagName === 'INPUT' && /^(text|search|email|url|tel)$/i.test(e.type)))) throw Error('Target cannot receive text');
@@ -103,7 +106,7 @@ const hostMatches = (hostname, pattern) => {
const name=hostname.toLowerCase().replace(/\.$/,''),p=String(pattern).toLowerCase();
return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name!==p.slice(2) : name===p;
};
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,appControls={}} = {}) {
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,onInteraction,appControls={}} = {}) {
if ([getContext,getSessions,contextCurrent,navigate,ai,inspect,audit].some(fn=>typeof fn!=='function')) throw TypeError('All adapter callbacks are required');
const snapshot = (args,signal,action) => {
const c = getContext(args.sessionId);
@@ -142,6 +145,11 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
const result = await initial.webContents.executeJavaScript(script(mode,args,initial.url),false);
check(initial,args,signal);
if (mode==='read') return {text:await classify(result?.text,initial,args,signal,initial.policy.agents.allowSensitiveRead===true)};
if(mode==='click' && result?.completed===true && onInteraction){
const source=result.target,target=source&&['a','button','input'].includes(source.tag)?{tag:source.tag}:undefined;
if(target&&['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(source.role))target.role=source.role;
if(target&&['x','y'].every(k=>Number.isSafeInteger(source[k])&&source[k]>=0&&source[k]<=100000)){target.x=source.x;target.y=source.y;}
await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.url,outcome:'completed',sensitiveField:false,...(target?{target}:{})});check(initial,args,signal);}
return {completed:result?.completed===true};
};
const appActions=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
@@ -181,7 +189,20 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
sessions:async(args={}, {signal}={})=>{
const c=getContext();
if(signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.policy.agents.scopes?.includes('sessions') || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
return getSessions().map(item=>({id:item.id,title:String(item.title || 'Tab').slice(0,160),foreground:item.foreground===true}));
const version=c.policyVersion??c.policy.version;
const inventory=()=>{const rows=getSessions();if(!Array.isArray(rows)||rows.length>1000)throw Error('Invalid managed inventory');return rows.map(item=>{if(typeof item.title!=='string'||item.title.length>200)throw Error('Invalid managed title');return {id:item.id,title:item.title||'Tab',foreground:item.foreground===true};});};
const rows=inventory(),binding=JSON.stringify(rows);
const current=()=>{const latest=getContext();if(signal?.aborted||!latest?.ready||!latest.policy?.agents?.enabled||!latest.policy.agents.scopes?.includes('sessions')||latest.policy!==c.policy||(latest.policyVersion??latest.policy.version)!==version||(latest.policy.expiresAt&&Date.parse(latest.policy.expiresAt)<=Date.now())||(c.token&&!contextCurrent(c.token))||JSON.stringify(inventory())!==binding)throw Error('Managed session changed during action');};
// One bounded classification avoids reloading local OCR/semantic models
// per tab. A blocked aggregate masks every title; opaque IDs remain usable.
const candidates=rows.filter(row=>!secret(row.title));
let allow=false;
if(candidates.length){
const verdict=await inspect(candidates.map(row=>row.title).join('\n'),{...c,action:'sessions'});current();
if(!verdict||!['allow','block'].includes(verdict.decision)||verdict.coverage?.complete===false||/uninspectable|size-limit|inspection-incomplete|unavailable|failure|failed|timeout|unsupported|encrypted|parser/i.test(verdict.ruleId||''))throw Error('Inspection unavailable');
allow=verdict.decision==='allow';
}
current();return rows.map(row=>({...row,title:allow&&!secret(row.title)?row.title.slice(0,160):'Protected page'}));
},
navigate:async(args,{signal}={})=>{const initial=snapshot(args,signal,'navigate');const url=new URL(args.url);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(url.href,initial.policy)!==true || (initial.policy.agents.allowedHosts.length && !initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Destination blocked');const refreshed=await navigate(url.href,initial,{signal});if(!refreshed || refreshed.sessionId!==initial.sessionId || refreshed.webContents!==initial.webContents || (refreshed.policyVersion ?? refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');check({...refreshed,action:initial.action,version:initial.version,url:url.href},args,signal,url.href);return {completed:true};},
read:dom('read'),click:dom('click'),type:dom('type'),select:dom('select'),check:dom('check'),
+5 -4
View File
@@ -4,14 +4,15 @@ function clipboardDOM(mode,url,token,operation,payload,limit){
if(location.href!==url||window.top!==window)throw Error('Clipboard frame changed');
const field=document.activeElement;
const editable=e=>e&&(e.tagName==='TEXTAREA'||e.tagName==='INPUT'&&/^(text|search|email|url|tel)$/i.test(e.type))&&!e.disabled&&!e.readOnly;
const sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)if(/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|master/i.test(['name','id','autocomplete','aria-label'].map(a=>p.getAttribute(a)||'').join(' ')))return true;return /^(password|file|hidden)$/i.test(e?.type||'');};
const sensitive=e=>{for(let p=e;p&&p!==document.body;p=p.parentElement)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(' ')))return true;return /^(password|file|hidden)$/i.test(e?.type||'');};
const visible=e=>{if(!e||e.closest('[hidden],[aria-hidden="true"],[inert]'))return false;let p=e,count=0;while(p&&count++<64){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'||s.opacity==='0')return false;p=p.parentElement;}return !p&&e.getClientRects().length>0;};
const rangeSafe=range=>{const ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;if(!ancestor?.isConnected||!visible(ancestor)||sensitive(ancestor)||ancestor.closest('input,textarea,[contenteditable],script,style,template,noscript')||ancestor.querySelector?.('input,textarea,[contenteditable]'))return false;const walker=document.createTreeWalker(ancestor,NodeFilter.SHOW_ELEMENT);let e,visited=0;while((e=walker.nextNode())){if(++visited>2048)return false;if(range.intersectsNode(e)&&(!visible(e)||sensitive(e)||/^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT)$/.test(e.tagName)))return false;}return true;};
const targetFacts=(e,rect)=>{const target={tag:/^[A-Z][A-Z0-9]{0,31}$/.test(e.tagName)?e.tagName.toLowerCase():'unknown'};const role=e.getAttribute?.('role');if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(role))target.role=role;if(rect){const x=rect.left+rect.width/2,y=rect.top+rect.height/2;if(Number.isFinite(x)&&Number.isFinite(y)&&x>=0&&y>=0&&x<=innerWidth&&y<=innerHeight){target.x=Math.round(x);target.y=Math.round(y);}}return target;};
if(mode==='stage'){
const snapshot={token,operation,field};let text='';
if(field&&(field.tagName==='INPUT'||field.tagName==='TEXTAREA'||field.isContentEditable)){
if(!editable(field)||sensitive(field)||!visible(field))throw Error('Clipboard target denied');
snapshot.kind='field';snapshot.value=field.value;if(snapshot.value.length>limit)throw Error('Clipboard target size limit');snapshot.start=field.selectionStart;snapshot.end=field.selectionEnd;
snapshot.target=targetFacts(field,field.getBoundingClientRect?.());snapshot.kind='field';snapshot.value=field.value;if(snapshot.value.length>limit)throw Error('Clipboard target size limit');snapshot.start=field.selectionStart;snapshot.end=field.selectionEnd;
if(!Number.isInteger(snapshot.start)||!Number.isInteger(snapshot.end))throw Error('Clipboard selection unsupported');
text=operation==='paste'?'':snapshot.value.slice(snapshot.start,snapshot.end);
}else{
@@ -19,10 +20,10 @@ function clipboardDOM(mode,url,token,operation,payload,limit){
const selection=getSelection();if(!selection||selection.rangeCount!==1||selection.isCollapsed)throw Error('Clipboard selection unavailable');
const range=selection.getRangeAt(0),ancestor=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;
if(!rangeSafe(range))throw Error('Clipboard rich selection unsupported');
snapshot.kind='range';snapshot.range=range.cloneRange();text=range.toString();
snapshot.target=targetFacts(ancestor,range.getBoundingClientRect?.());snapshot.kind='range';snapshot.range=range.cloneRange();text=range.toString();
}
if(typeof text!=='string'||text.length>limit||text.includes('\0'))throw Error('Clipboard size limit');
snapshot.text=text;globalThis.__safetynetClipboardSnapshot=snapshot;return {text};
snapshot.text=text;globalThis.__safetynetClipboardSnapshot=snapshot;return {text,target:snapshot.target};
}
const snapshot=globalThis.__safetynetClipboardSnapshot;if(mode!=='validate')delete globalThis.__safetynetClipboardSnapshot;
if(!snapshot||snapshot.token!==token||snapshot.operation!==operation)throw Error('Clipboard snapshot unavailable');
+8 -3
View File
@@ -1,11 +1,12 @@
'use strict';
const {randomUUID}=require('node:crypto');
function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,timeoutMs=10000}){
function createClipboardGuard({getContext,stage,validate,commit,discard,readClipboard,writeClipboard,inspect,audit,onCompleted,timeoutMs=10000}){
const targetFacts=value=>{if(!value||!['a','button','input','textarea','select','div','span','p','body','unknown'].includes(value.tag))return undefined;const target={tag:value.tag};if(['button','link','textbox','checkbox','radio','combobox','menuitem','option'].includes(value.role))target.role=value.role;if(['x','y'].every(k=>Number.isSafeInteger(value[k])&&value[k]>=0&&value[k]<=100000)) {target.x=value.x;target.y=value.y;}return target;};
let pending=null;
const current=c=>{const n=getContext(c.webContents);return n.ready&&n.tabId===c.tabId&&n.activation===c.activation&&n.policy===c.policy&&n.url===c.url&&n.navigationId===c.navigationId&&n.webContents===c.webContents&&n.webContents?.focusedFrame===n.webContents?.mainFrame&&Date.parse(n.policy?.expiresAt)>Date.now();};
async function run(operation,webContents){
const c=getContext(webContents),token=randomUUID();let text='',timer,expired=false;
const denied=async ruleId=>{await audit('clipboard.block',ruleId);return{completed:false,ruleId};};
const denied=async ruleId=>{await audit('clipboard.block',ruleId,{action:operation,sessionId:c.tabId,outcome:'denied'});return{completed:false,ruleId};};
const work=async()=>{try{
if(!['copy','cut','paste'].includes(operation)||!current(c))return denied('clipboard-context-denied');
const limit=Math.min(c.policy.security?.clipboard?.maxTextBytes||0,c.policy.dlp.maxBytes);
@@ -19,7 +20,7 @@ function createClipboardGuard({getContext,stage,validate,commit,discard,readClip
if(!current(c)||expired)return denied('clipboard-context-changed');
if(verdict?.decision!=='allow')return denied(verdict?.ruleId||'clipboard-inspection-failed');
// Persist authorization before side effects; never audit raw clipboard text.
await audit(`clipboard.${operation}`);
await audit(`clipboard.${operation}`,undefined,{action:operation,sessionId:c.tabId,outcome:'authorized'});
if(!current(c)||expired)return denied('clipboard-context-changed');
const args={operation,token,url:c.url,text,limit,webContents:c.webContents};
if(operation==='cut'){
@@ -31,6 +32,10 @@ function createClipboardGuard({getContext,stage,validate,commit,discard,readClip
const result=await commit(args);
if(result?.completed!==true||!current(c)||expired)return denied('clipboard-target-changed');
if(operation==='copy'){await writeClipboard(text);if(!current(c)||expired)return denied('clipboard-context-changed');}
if(onCompleted && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(c.tabId||'')) {
await onCompleted({action:operation,sessionId:c.tabId,pageUrl:c.url,outcome:'completed',sensitiveField:false,...(targetFacts(snapshot.target)?{target:targetFacts(snapshot.target)}:{}),bytes:Buffer.byteLength(text)});
if(!current(c)||expired)return denied('clipboard-context-changed');
}
return {completed:true};
}finally{try{await discard?.({token,webContents:c.webContents});}catch{}}};
try{return await Promise.race([work(),new Promise(resolve=>{timer=setTimeout(()=>{expired=true;Promise.resolve(discard?.({token,webContents:c.webContents})).catch(()=>{});denied('clipboard-timeout').then(resolve,()=>resolve({completed:false,ruleId:'clipboard-timeout'}));},timeoutMs);})]);}
+5 -5
View File
@@ -10,8 +10,8 @@ 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 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']);
const operations=new Set(['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 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 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']);
@@ -34,7 +34,7 @@ function attributes(input){const out={};if(!input||typeof input!=='object')retur
function endpointAllowed(value){try{const u=new URL(value);if(u.username||u.password||u.search||u.hash)return false;const host=u.hostname.replace(/^\[|\]$/g,'');return u.protocol==='https:'||(u.protocol==='http:'&&(host==='::1'||/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(host)&&host.split('.').every(p=>+p<=255)));}catch{return false;}}
function valid(config){return config&&config.enabled===true&&['operational','debug'].includes(config.mode)&&endpointAllowed(config.endpoint)&&['sampleRate','debugSampleRate'].every(k=>Number.isFinite(config[k])&&config[k]>=0&&config[k]<=1)&&Number.isSafeInteger(config.maxQueueSize)&&config.maxQueueSize>=16&&config.maxQueueSize<=4096&&Number.isSafeInteger(config.flushIntervalMs)&&config.flushIntervalMs>=250&&config.flushIntervalMs<=10000&&Number.isSafeInteger(config.maxRecordAgeMs)&&config.maxRecordAgeMs>=1000&&config.maxRecordAgeMs<=300000&&Number.isSafeInteger(config.exportTimeoutMs)&&config.exportTimeoutMs>=250&&config.exportTimeoutMs<=10000;}
const defaults={enabled:false,mode:'operational',sampleRate:0.1,debugSampleRate:1,maxQueueSize:512,flushIntervalMs:2000,maxRecordAgeMs:30000,exportTimeoutMs:2000};
function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0;
function createDiagnostics(){const storage=new AsyncLocalStorage();let generation=null,configurationKey='',closed=false;const retiring=new Set();let activeSpans=0,lifetimeFailedExports=0;
// Parser timing is untrusted metadata: validate the entire batch before creating
// spans, and attach it only to a currently recording browser operation.
function importStages(envelope){try{const current=generation,parent=storage.getStore();if(!current?.active||current.config.mode!=='debug'||parent?.generation!==current||!parent.span.isRecording()||activeSpans>=128)return;
@@ -42,7 +42,7 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
const imported=new Map();for(const item of validated){if(!current.active||generation!==current||!parent.span.isRecording())return;const ancestor=item.parentId===null?parent.span:imported.get(item.parentId);if(!ancestor)return;const child=current.tracer.startSpan(`inspector.${item.stage}`,{startTime:base+item.start,attributes:attributes({component:'inspector',status:item.status,durationMs:item.duration})},trace.setSpan(ROOT_CONTEXT,ancestor));imported.set(item.id,child);child.setStatus({code:item.status==='ok'?SpanStatusCode.OK:SpanStatusCode.ERROR});child.end(base+item.start+item.duration);}
}catch{/* Diagnostics must never change inspection results. */}}
function retire(current){if(!current)return;current.active=false;for(const agent of current.agents)agent.destroy();const pending=Promise.allSettled([current.tracerProvider?.shutdown(),current.loggerProvider?.shutdown()]);retiring.add(pending);pending.finally(()=>retiring.delete(pending));}
function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0)current.failedExports++;callback(result);});}catch{current.failedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};}
function gatedExporter(exporter,current){return {export(records,callback){if(!current.active){callback({code:0});return;}const now=Date.now(),fresh=records.filter(record=>{const timestamp=record.endTime||record.hrTime||record.hrTimeObserved;return timestamp&&now-(timestamp[0]*1000+timestamp[1]/1e6)<=current.config.maxRecordAgeMs;});if(!fresh.length){callback({code:0});return;}try{exporter.export(fresh,result=>{if(result.code!==0){current.failedExports++;lifetimeFailedExports++;}callback(result);});}catch{current.failedExports++;lifetimeFailedExports++;callback({code:1});}},shutdown:()=>exporter.shutdown(),forceFlush:()=>exporter.forceFlush?.()||Promise.resolve()};}
function configure(input={},context={}){if(closed)return false;const config={...defaults,...input};const key=JSON.stringify([config,attributes(context)]);if(key===configurationKey)return !!generation?.active;configurationKey=key;const previous=generation;generation=null;retire(previous);if(!valid(config)||retiring.size>=4)return false;
let current;try{current={active:true,config,context:attributes(context),agents:new Set(),failedExports:0};
const agentFactory=async protocol=>{const Base=protocol==='https:'?https.Agent:http.Agent;class PrivateAgent extends Base{addRequest(request,options){if(!current.active){request.destroy(Error('Diagnostics disabled'));return;}for(const key of request.getHeaderNames())if(!['host','content-type','content-length','user-agent'].includes(key.toLowerCase()))request.removeHeader(key);super.addRequest(request,options);}}
@@ -63,6 +63,6 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
}
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,activeSpans})};
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})};
}
module.exports={createDiagnostics,attributes,endpointAllowed};
+20 -1
View File
@@ -19,4 +19,23 @@ function buildDownloadEvidence({url,name,mime,facts,decision,ruleId},policy){
if(typeof ruleId==='string'&&ruleId.length<=100&&!/[\x00-\x1f\x7f]/.test(ruleId))result.ruleId=ruleId;
return validateDownloadEvidence(result)?result:null;
}
module.exports={buildDownloadEvidence,validateDownloadEvidence};
const {InteractionEvidenceSchema}=require('../../packages/core/evidence-payload.cjs');
function validateInteractionEvidence(value){
if(!InteractionEvidenceSchema.safeParse(value).success)return false;
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;
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;
// Native adapters expose bounded lowercase tags. Preserve their meaning in
// the canonical evidence representation without accepting arbitrary DOM text.
target={...target,tag:target.tag.toUpperCase()};
}
// 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})};
return validateInteractionEvidence(payload)?structuredClone(payload):null;
}
module.exports={buildDownloadEvidence,validateDownloadEvidence,buildInteractionEvidence,validateInteractionEvidence};
+20 -10
View File
@@ -1,16 +1,26 @@
'use strict';
const {validateDownloadEvidence}=require('./evidence-capture.cjs');
function collectionAllowed(context){return !!context?.ready&&context.policy?.evidence?.enabled===true&&context.policy.evidence.captureDownloads===true&&Date.parse(context.policy.expiresAt)>Date.now();}
function validatedEvidence(payload){if(!validateDownloadEvidence(payload))throw Error('Invalid protected download evidence');return structuredClone(payload);}
function stripPendingEvidence(queue){return queue.map(({protectedEvidence,...event})=>event);}
const {validateDownloadEvidence,validateInteractionEvidence}=require('./evidence-capture.cjs');
function evidenceType(record){return record?.protectedEvidence?.type??'download';}
function policyAllows(context,type='download'){return ['download','interaction'].includes(type)&&context?.policy?.evidence?.enabled===true&&context.policy.evidence[type==='download'?'captureDownloads':'captureInteractions']===true&&Date.parse(context.policy.expiresAt)>Date.now();}
function collectionAllowed(context){return !!context?.ready&&policyAllows(context,context?.type??'download');}
function validatedEvidence(payload,type='download'){if(!(type==='download'?validateDownloadEvidence(payload):type==='interaction'&&validateInteractionEvidence(payload)))throw Error('Invalid protected evidence');return structuredClone(payload);}
function stripPendingEvidence(queue,context){return queue.map(record=>{if(!record.protectedEvidence)return record;if(context&&policyAllows(context,evidenceType(record)))return record;const {protectedEvidence,...event}=record;return event;});}
async function deliverAuditRecord({record,send,getContext}){
const {protectedEvidence,...event}=record;await send('/api/device/events',event);
if(!protectedEvidence)return {eventDelivered:true,evidenceDelivered:false};
const context=getContext();
if(protectedEvidence.deviceId!==context?.deviceId||context?.policy&&(context.policy.evidence?.enabled!==true||context.policy.evidence.captureDownloads!==true||!(Date.parse(context.policy.expiresAt)>Date.now())))return {eventDelivered:true,evidenceDiscarded:true};
if(!context?.ready||!context.policy)throw Error('Evidence delivery paused until managed context is ready');
const payload=validatedEvidence(protectedEvidence.payload);
try{const result=await send('/api/device/evidence',{eventId:event.eventId,type:'download',payload});if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result?.id||'')||!Number.isSafeInteger(result.sourceEventId)||result.sourceEventId<1)throw Error('Evidence acknowledgement invalid');return {eventDelivered:true,evidenceDelivered:true};}
const type=evidenceType(record);
function state(){const context=getContext();if(protectedEvidence.deviceId!==context?.deviceId||context?.policy&&!policyAllows(context,type))return 'discard';if(!context?.ready||!context.policy)return 'pause';return 'allow';}
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';
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});
// A successful network response must not resurrect collection after a
// concurrent policy revocation or a change of enrolled device.
const after=state();if(after==='discard')return {eventDelivered:true,evidenceDiscarded:true};if(after==='pause')throw Error('Evidence delivery paused until managed context is ready');
if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(result?.id||'')||!Number.isSafeInteger(result.sourceEventId)||result.sourceEventId<1)throw Error('Evidence acknowledgement invalid');return {eventDelivered:true,evidenceDelivered:true};}
catch(error){if(error.status===403)return {eventDelivered:true,evidenceDiscarded:true};throw error;}
}
module.exports={validatedEvidence,collectionAllowed,stripPendingEvidence,deliverAuditRecord};
module.exports={validatedEvidence,collectionAllowed,stripPendingEvidence,deliverAuditRecord,evidenceType,policyAllows};
+32 -15
View File
@@ -146,11 +146,14 @@ function managementUrl(value){
return u.origin;
}
const managementRequests=new Map();
const evidenceRequestTypes=new Map(),evidenceSendStorage=new (require('node:async_hooks').AsyncLocalStorage)();
function request(...args){return diagnosticSpan('browser.management',{operation:args[1]==='/api/device/ai'?'ai':args[1]==='/api/device/enroll'?'enroll':'sync'},()=>requestImpl(...args));}
async function requestImpl(server,route,body,credential){
if(route==='/api/device/evidence'){
if(!device||device.server!==server||device.token!==credential)throw Error('Protected evidence identity unavailable');
return require('./management-channel.cjs').protectedEvidenceRequest({device,request,save:saveDevice,payload:body});
const identity=device,type=body?.type;if(!['download','interaction'].includes(type))throw Error('Invalid protected evidence type');
const assertCurrent=()=>{if(device!==identity||!require('./evidence-delivery.cjs').policyAllows({policy},type))throw Object.assign(Error('Protected evidence collection disabled'),{status:403});if(!ready)throw Error('Evidence delivery paused until managed context is ready');};
assertCurrent();return evidenceSendStorage.run({type},()=>require('./management-channel.cjs').protectedEvidenceRequest({device,request,save:saveDevice,payload:body,assertCurrent,traceOperation:(operation,fn)=>diagnosticSpan('browser.evidence',{operation},fn)}));
}
if(route==='/api/device/policy'){
if(!device||device.server!==server||device.token!==credential)throw Error('Protected management identity unavailable');
@@ -159,13 +162,13 @@ async function requestImpl(server,route,body,credential){
const headers={};if(body)headers['content-type']='application/json';if(credential)headers.authorization=`Bearer ${credential}`;
const correlation=diagnosticCorrelation();if(correlation.traceId&&correlation.spanId)headers.traceparent=`00-${correlation.traceId}-${correlation.spanId}-01`;
if(route==='/api/device/enroll'||route==='/api/device/sso/poll')headers['x-safetynet-pq-bootstrap']='1';
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),route.endsWith('/ai')||route==='/api/device/library/propose'?65000:10000);managementRequests.set(controller,route);
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),route.endsWith('/ai')||route==='/api/device/library/propose'?65000:10000);managementRequests.set(controller,route);if(evidenceSendStorage.getStore()?.type&&route.startsWith('/api/device/channel/evidence'))evidenceRequestTypes.set(controller,evidenceSendStorage.getStore().type);
try{
const fetcher=managementSession?managementSession.fetch.bind(managementSession):fetch;
const r=await fetcher(`${server}${route}`,{method:body?'POST':'GET',headers,body:body?JSON.stringify(body):undefined,credentials:'omit',redirect:'error',signal:controller.signal});
const chunks=[];let size=0;for await(const part of r.body){size+=part.length;if(size>5*1024*1024)throw Error('Management response exceeds its limit');chunks.push(part);}
const data=JSON.parse(Buffer.concat(chunks).toString('utf8'));if(!r.ok && r.status!==202){const e=Error('Management request refused');e.status=r.status;throw e;}return data;
}finally{clearTimeout(timer);managementRequests.delete(controller);}
}finally{clearTimeout(timer);managementRequests.delete(controller);evidenceRequestTypes.delete(controller);}
}
function state(){return {startPage:{...(policy?.startPage||{}),news:ready?companyNews:[]},libraryAI:{suggestionsEnabled:ready&&!!policy?.ai.enabled&&!!policy?.library?.aiSuggestionsEnabled&&sectionAllowed(managedSettings(),'library')&&featureAllowed(managedSettings(),'ai'),autoApplyEnabled:ready&&!!policy?.library?.aiAutoApplyEnabled&&policy?.ai?.trustMode==='local',busy:libraryOrganizer?.isBusy()||false},bookmarks:bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),settings:managedSettings(),enrolled:!!device,deviceId:device?.id||'',ready,clipboardPending:clipboardGuard?.isPending()||false,...tabs?.summary(),tabsRevision:++tabsRevision,canNavigate:ready||!!(stickyBlock()&&managementHealthy),loginCheckPending:ready&&!!policy?.security.visualPhishing.enabled&&(!activeTab()?.security.visualApproval||activeTab().security.visualApproval.generation!==activeTab().navigationId),message,brand:policy?.brand||{name:'SafetyNet',color:'#137c66'},policyVersion:policy?.version||0,server:device?.server||'',url:view?.webContents.getURL()||'',aiEnabled:ready&&!!policy?.ai.enabled,documentScanEnabled:ready&&policy?.inspection?.documentAnalysisEnabled!==false,passwordManagerEnabled:ready&&!!policy?.passwordManager?.enabled,vaultLocked:vault?.status().locked??true,vaultExists:vault?.status().exists??fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),agentIdentityMode:policy?.agents.identityMode||'legacy',agentGrants:ready&&featureAllowed(managedSettings(),'agents')?approvedAgentGrants().map(({id,name,generation,expiresAt})=>({id,name,generation,expiresAt})):[],agentsEnabled:ready&&!!policy?.agents.enabled,agentConnected:!!agentService};}
function emit(){if(win&&!win.isDestroyed()){const settings=managedSettings();if(!settings.visible)toolsOpen=false;if(!sectionAllowed(settings,'appearance'))chromeHeight=settings.appearance.tabMode==='compact'?64:104;if(!featureAllowed(settings,'ai'))aiPanel=false;if(!featureAllowed(settings,'passwords')){vaultPanel=false;vault?.lock();}if(!libraryVisible(settings))leftSidebar=false;layout();win.webContents.send('managed:state',{...state(),pageTitle,pageColor,windowMaximized:win.isMaximized()});win.setTitle(`S Browser · ${state().brand.name}`);}}
@@ -192,7 +195,10 @@ function persistAudit(){
try{fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,safeStorage.encryptString(JSON.stringify(auditQueue)));fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temporary,target);}finally{if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch{}}
}
function enforceEvidencePolicy(){
if(policy&&(policy.evidence?.enabled!==true||policy.evidence.captureDownloads!==true||!(Date.parse(policy.expiresAt)>Date.now()))){for(const [controller,route]of managementRequests)if(route==='/api/device/evidence'||route.startsWith('/api/device/channel/evidence'))controller.abort();if(auditQueue.some(e=>e.protectedEvidence)){auditQueue=require('./evidence-delivery.cjs').stripPendingEvidence(auditQueue);persistAudit();}}
if(!policy)return;const delivery=require('./evidence-delivery.cjs');
for(const [controller,type]of evidenceRequestTypes)if(!delivery.policyAllows({policy},type))controller.abort();
const next=delivery.stripPendingEvidence(auditQueue,{policy});
if(next.some((record,index)=>record!==auditQueue[index])){auditQueue=next;persistAudit();}
}
async function flushAudit(){
@@ -200,31 +206,35 @@ async function flushAudit(){
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();}}
catch{}finally{flushing=false;}
}
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts){
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts,interactionFacts){
if(!device||!policy)return;
// Operational correlation contains no rule text, hostname or forensic payload.
try{diagnostics.event('browser.security',diagnosticContext({decision:kind.endsWith('.block')?'block':kind.endsWith('.denied')?'deny':kind.endsWith('.authorized')||kind.endsWith('.released')?'allow':'unknown',reason:ruleId}));}catch{}
if(auditQueue.length>=10000){quarantine('Security event queue is full. Contact your administrator.');return;}
try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy})?{protectedEvidence:{deviceId:device.id,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload)}}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;}
try{const file=fileFacts?require('../../packages/core/transfer-facts.cjs').transferFacts(fileFacts):undefined;const request=requestFacts?require('../../packages/core/request-transfer-facts.cjs').requestTransferFacts(requestFacts):undefined;const agent=agentFacts?require('../../packages/core/agent-audit.cjs').agentAuditFacts(agentFacts):undefined;const interaction=interactionFacts?require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.parse(interactionFacts):undefined;const evidenceType=evidencePayload?.action?'interaction':'download';auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...(request?{request}:{}),...(agent?{agent}:{}),...(interaction?{interaction}:{}),...(evidencePayload&&require('./evidence-delivery.cjs').collectionAllowed({ready,policy,type:evidenceType})?{protectedEvidence:{deviceId:device.id,type:evidenceType,payload:require('./evidence-delivery.cjs').validatedEvidence(evidencePayload,evidenceType)}}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();const eventId=auditQueue.at(-1).eventId;flushAudit();return eventId;}
catch{quarantine('Security events cannot be persisted. Contact your administrator.');}
}
function auditAgent(event){
const action=event.action==='agent.read'?'read':event.action||'agent-request',outcome=event.outcome==='blocked'?'denied':event.outcome;
const helper=require('../../packages/core/agent-audit.cjs'),session=helper.AgentAuditSchema.shape.sessionId.safeParse(event.sessionId);
const agent=helper.agentAuditFacts({action,outcome,...(event.scope?{scope:event.scope}:{}),...(session.success&&session.data?{sessionId:session.data}:{}),...(event.principalId?{principalId:event.principalId,credentialGeneration:event.generation}:{})});
return audit(['denied','failed'].includes(outcome)?'agent.denied':'agent.action',`${event.action||'agent-request'}:${event.outcome}`,undefined,undefined,undefined,undefined,agent);
return audit(['denied','failed'].includes(outcome)?'agent.denied':'agent.action',`${event.action||'agent-request'}:${event.outcome}`,undefined,undefined,undefined,event.protectedInteraction,agent);
}
function applyPolicy(envelope){return diagnosticSpan('browser.policy',{},()=>applyPolicyImpl(envelope));}
async function applyPolicyImpl(envelope){
const next=assertPolicyAssignment(core.verifyPolicy(envelope,device.publicKey,device.minimumVersion||0),device.id);
const next=await diagnosticSpan('browser.policy',{operation:'policy.verify'},()=>assertPolicyAssignment(core.verifyPolicy(envelope,device.publicKey,device.minimumVersion||0),device.id));
const {nextBookmarks,nextBookmarkJSON}=await diagnosticSpan('browser.policy',{operation:'policy.bookmarks'},async()=>{
const nextBookmarks=(await import('../../packages/core/bookmarks.mjs')).verifyBookmarkSnapshot(envelope.bookmarks,device.publicKey,{deviceId:device.id,policyVersion:next.version,minimumRevision:device.bookmarkRevision||0});
const nextBookmarkJSON=JSON.stringify(nextBookmarks.items);
if(nextBookmarks.revision===(device.bookmarkRevision||0)&&device.bookmarkDigest&&require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex')!==device.bookmarkDigest||nextBookmarks.revision===personalBookmarks.revision&&bookmarkSnapshotJSON&&nextBookmarkJSON!==bookmarkSnapshotJSON)throw Error('Bookmarks changed without a new revision');
return {nextBookmarks,nextBookmarkJSON};
});
const acceptBookmarks=()=>{personalBookmarks={revision:nextBookmarks.revision,items:nextBookmarks.items};bookmarkSnapshotJSON=nextBookmarkJSON;device.bookmarkRevision=nextBookmarks.revision;device.bookmarkDigest=require('node:crypto').createHash('sha256').update(nextBookmarkJSON).digest('hex');saveDevice(device);};
if(process.env.SAFETYNET_POLICY_KEY_FILE&&fs.readFileSync(process.env.SAFETYNET_POLICY_KEY_FILE,'utf8').trim()!==device.publicKey.trim())throw Error('Policy signing key does not match deployment pin');
if(policy&&next.version===policy.version){
// Same versions must describe identical policy; reject equivocation.
if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();await reconcileAgentPolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return;
if(JSON.stringify(next)!==JSON.stringify(policy))throw Error('Policy changed without a new version');acceptBookmarks();await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy());enforceEvidencePolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return;
}
configureDiagnostics({enabled:false});quarantine('Applying company policy…');
const proxy=next.proxy;
@@ -234,10 +244,10 @@ async function applyPolicyImpl(envelope){
app.configureHostResolver(resolverOptions(next.enterprise?.dns));
await browserSession.clearHostResolverCache();recentRequests.clear();
await managementSession.clearHostResolverCache();
policy=next;if(!next.evidence.enabled||!next.evidence.captureDownloads)enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics);
for(const tab of tabs.all()){await configureVisualPage(next.security.visualPhishing.enabled,tab);if(!next.browser.allowDevTools){tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools();}}
await reconcileAgentPolicy();
await audit('policy.applied');
policy=next;enforceEvidencePolicy();device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics);
await diagnosticSpan('browser.policy',{operation:'policy.page-configure'},async()=>{for(const tab of tabs.all()){await configureVisualPage(next.security.visualPhishing.enabled,tab);if(!next.browser.allowDevTools){tab.security.developerToolsGuard?.close();tab.view.webContents.closeDevTools();}}});
await diagnosticSpan('browser.policy',{operation:'policy.agent-reconcile'},()=>reconcileAgentPolicy());
await diagnosticSpan('browser.policy',{operation:'policy.audit'},()=>audit('policy.applied'));
}
async function sync(){
if(!device||syncing)return;syncing=true;
@@ -385,7 +395,10 @@ async function ensureAgentAPI(){
if(!localInspector)return {decision:'block'};const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy));
verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result;
}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external;
},audit:auditAgent});
},audit:auditAgent,onInteraction:async metadata=>{
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'agent-adapter'},policy));if(!payload)return;
const actor=agentActorStorage.getStore();await auditAgent({action:'click',outcome:'completed',sessionId:metadata.sessionId,...(actor?.principalId?{principalId:actor.principalId,generation:actor.generation}:{}),protectedInteraction:payload});
}});
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','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
@@ -640,7 +653,11 @@ app.whenReady().then(async()=>{
readClipboard:async limit=>{const items=await clipboard.read();if(items.length!==1)throw Error('Clipboard format unsupported');const item=items[0];if(!item.types.includes('text/plain')||item.types.some(type=>type!=='text/plain'&&!/^electron application\/osclipboard;format="(?:CF_TEXT|CF_UNICODETEXT|CF_LOCALE)"$/.test(type)))throw Error('Rich clipboard denied');const blob=await item.getType('text/plain');if(blob.size>limit)throw Error('Clipboard size limit');return blob.text();},
writeClipboard:text=>clipboard.writeText(text),
inspect:async(bytes,currentPolicy,operation)=>{let verdict=clipboardCore.inspectClipboard(bytes,currentPolicy,operation);if(verdict.decision!=='allow')return verdict;if(currentPolicy.dlp.semantic.enabled||currentPolicy.security.yara.enabled){if(!localInspector)return {decision:'block',ruleId:'clipboard-inspector-unavailable'};verdict=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,currentPolicy));}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,currentPolicy,'clipboard'));return external.decision==='allow'?verdict:external;},
audit:async(kind,ruleId)=>{await audit(kind,ruleId);if(kind==='clipboard.block'&&ready){message=`Clipboard blocked by company policy (${ruleId}).`;emit();}}
onCompleted:async metadata=>{
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'guarded-clipboard'},policy));
await audit(`clipboard.${metadata.action}`,undefined,undefined,undefined,undefined,payload,undefined,{action:metadata.action,sessionId:metadata.sessionId,outcome:'completed'});
},
audit:async(kind,ruleId,facts)=>{const parsed=require('../../packages/core/interaction-facts.cjs').InteractionFactsSchema.safeParse(facts);await audit(kind,ruleId,undefined,undefined,undefined,undefined,undefined,parsed.success?parsed.data:undefined);if(kind==='clipboard.block'&&ready){message=`Clipboard blocked by company policy (${ruleId}).`;emit();}}
});
installClipboardControls(win.webContents);hooks();
ipcMain.on('managed:visual-policy',event=>{event.returnValue={enabled:!!tabs.fromContents(event.sender.id)&&!!policy?.security.visualPhishing.enabled};});
+12 -6
View File
@@ -19,19 +19,25 @@ async function protectedPolicyRequest({device,request,save}){
try{const response=await request(device.server,'/api/device/channel/policy',context.request,device.token);return channel.finish(response,challenge,context);}
finally{context.responseKey.fill(0);}
}
async function protectedEvidenceRequest({device,request,save,payload}){
async function protectedEvidenceRequest({device,request,save,payload,assertCurrent=()=>{},traceOperation=async(_name,fn)=>fn()}){
assertCurrent();
await initializeSigningPin({device,request,save});
assertCurrent();
const crypto=channel.evidenceChannel;
const challenge=await request(device.server,'/api/device/channel/evidence/challenge',{},device.token);
const challenge=await traceOperation('evidence.challenge',()=>request(device.server,'/api/device/channel/evidence/challenge',{},device.token));
assertCurrent();
crypto.verifyChallenge(challenge,device.id,device.publicKey,device.channelSignatureKey);
const context=crypto.prepare(challenge,payload);
let context;
try{
const response=await request(device.server,'/api/device/channel/evidence',context.request,device.token);
await traceOperation('evidence.encrypt',()=>{context=crypto.prepare(challenge,payload);});
assertCurrent();
const response=await traceOperation('evidence.send',()=>request(device.server,'/api/device/channel/evidence',context.request,device.token));
// Status is trusted only inside the authenticated encrypted acknowledgement.
const result=crypto.finish(response,challenge,context);
assertCurrent();
const result=await traceOperation('evidence.ack',()=>crypto.finish(response,challenge,context));assertCurrent();
if(!result||Object.keys(result).sort().join(',')!=='body,status'||!Number.isInteger(result.status)||result.status<200||result.status>599||!result.body||typeof result.body!=='object'||Array.isArray(result.body))throw Error('Protected evidence acknowledgement invalid');
if(result.status>=300){const error=Error('Protected evidence submission refused');error.status=result.status;throw error;}
return result.body;
}finally{context.responseKey.fill(0);}
}finally{context?.responseKey.fill(0);}
}
module.exports={protectedPolicyRequest,protectedEvidenceRequest,deploymentPin};
+3 -1
View File
@@ -3,6 +3,7 @@ import {installRequestTelemetry,traceOperation} from './telemetry.mjs';
import transferFacts from '../../packages/core/transfer-facts.cjs';
import requestTransferFacts from '../../packages/core/request-transfer-facts.cjs';
import agentAudit from '../../packages/core/agent-audit.cjs';
import interactionAudit from '../../packages/core/interaction-facts.cjs';
import {installEvidence} from './evidence.mjs';
import {installIncidents} from './incidents.mjs';
import { installBookmarkRoutes,globalBookmarksValid } from './bookmarks.mjs';
@@ -107,7 +108,8 @@ export function createApp(store,config) {
store.db.prepare('UPDATE devices SET last_seen=?,policy_version=? WHERE id=?').run(new Date().toISOString(),b.policyVersion,req.device.id);res.json({ok:true});
});
app.post('/api/device/events',device,(req,res)=>{
const b=z.object({eventId:z.string().uuid(),kind:z.enum(['dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested','phishing.block','phishing.error','phishing.scanned','clipboard.block','clipboard.copy','clipboard.cut','clipboard.paste']),ruleId:z.string().max(100).optional(),host:z.string().max(253).optional(),policyVersion:z.number().int().positive(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional(),file:transferFacts.FileFactsSchema.optional(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional()}).strict().parse(req.body);
const b=z.object({eventId:z.string().uuid(),kind:z.enum(['dlp.block','navigation.block','download.block','policy.applied','browser.started','agent.action','agent.denied','library.ai.proposed','library.ai.applied','library.ai.reconcile','devtools.open-requested','devtools.opened','devtools.closed','devtools.denied','download.scan','download.released','upload.authorized','upload.block','dns.block','content.block','vault.created','vault.unlocked','vault.locked','vault.saved','vault.deleted','vault.filled','vault.fill-requested','phishing.block','phishing.error','phishing.scanned','clipboard.block','clipboard.copy','clipboard.cut','clipboard.paste']),ruleId:z.string().max(100).optional(),host:z.string().max(253).optional(),policyVersion:z.number().int().positive(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional(),file:transferFacts.FileFactsSchema.optional(),request:requestTransferFacts.RequestFactsSchema.optional(),agent:agentAudit.AgentAuditSchema.optional(),interaction:interactionAudit.InteractionFactsSchema.optional()}).strict().parse(req.body);
if(b.interaction&&!interactionAudit.interactionFactsForEvent(b.kind,b.interaction))return res.status(400).json({error:'Interaction facts require a matching clipboard lifecycle event'});
if(b.agent&&!agentAudit.agentFactsForEvent(b.kind,b.agent))return res.status(400).json({error:'Agent facts require a matching agent lifecycle event'});
if(b.file&&!b.kind.startsWith('download.')&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'File facts require a transfer event'});
if(b.request&&!b.kind.startsWith('upload.'))return res.status(400).json({error:'Request facts require an upload event'});
+13 -6
View File
@@ -5,6 +5,7 @@ import {randomBytes,randomUUID,createCipheriv,createDecipheriv,createHash} from
import {bootstrapAllowed} from './admin-access.mjs';
import {installEvidenceChannel} from './evidence-channel.mjs';
import {assertPrivateEvidenceKey} from './evidence-key.mjs';
import {InteractionEvidenceSchema} from '../../packages/core/evidence.mjs';
const text=z.string().max(16000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters forbidden');
const url=z.string().max(4096).refine(v=>{try{const u=new URL(v);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Evidence URLs require HTTP(S), no credentials, query or fragment');
@@ -35,11 +36,17 @@ function includesCredential(payload){
}
function sourceMatches(source,payload,type){
let metadata;try{metadata=JSON.parse(source.metadata);}catch{return false;}
if(type==='interaction'&&['clipboard.copy','clipboard.cut','clipboard.paste'].includes(source.kind)&&payload.action!==source.kind.split('.')[1])return false;
if(type==='interaction'){
if(payload.outcome!=='completed'||!payload.sessionId)return false;
if(source.kind==='agent.action'){
if(payload.captureSource!=='agent-adapter'||metadata.agent?.action!=='click'||metadata.agent.outcome!=='completed'||payload.action!=='click'||metadata.agent.sessionId!==payload.sessionId)return false;
}else{
if(payload.captureSource!=='guarded-clipboard'||!['clipboard.copy','clipboard.cut','clipboard.paste'].includes(source.kind)||payload.action!==source.kind.split('.')[1]||metadata.interaction?.action!==payload.action||metadata.interaction?.outcome!=='completed'||metadata.interaction?.sessionId!==payload.sessionId)return false;
}
}
if(type==='download'&&source.kind==='download.released'&&payload.decision!=='allow'||type==='download'&&source.kind==='download.block'&&payload.decision!=='block')return false;
if(type==='download'&&metadata.file){for(const field of ['sha256','bytes'])if(metadata.file[field]!==undefined&&payload[field]!==metadata.file[field])return false;}
if(metadata.host){try{if(new URL(payload.sourceUrl??payload.pageUrl).hostname.toLowerCase()!==String(metadata.host).toLowerCase())return false;}catch{return false;}}
if(type==='interaction'&&source.kind==='agent.action'&&typeof metadata.ruleId==='string'){const action=metadata.ruleId.split(':')[0];if(['click','copy','cut','paste','submit'].includes(action)&&payload.action!==action)return false;}
return true;
}
function aad(row){return Buffer.from(JSON.stringify([1,row.id,row.source_event_id,row.device_id,row.type,row.key_id,row.created_at,row.expires_at]));}
@@ -61,10 +68,10 @@ export function installEvidence(app,store,config,device,admin){
function recordEvidence(req,res){
let plain;try{
const b=input.parse(req.body),policy=store.effectivePolicy(req.device.id);
if(Date.parse(policy.expiresAt)<=Date.now()||!policy.evidence.enabled||b.type==='download'&&!policy.evidence.captureDownloads)return res.status(403).json({error:'Evidence collection is disabled by current policy'});
const payload=payloads[b.type].parse(b.payload);if(includesCredential(payload))return res.status(400).json({error:'Recognizable credentials are excluded from evidence'});if(b.type==='interaction'&&payload.content!==undefined&&payload.contentSha256!==undefined&&createHash('sha256').update(payload.content,'utf8').digest('hex')!==payload.contentSha256)return res.status(400).json({error:'Evidence content digest mismatch'});plain=Buffer.from(JSON.stringify(payload));if(plain.length>65536)return res.status(413).json({error:'Evidence too large'});
if(Date.parse(policy.expiresAt)<=Date.now()||!policy.evidence.enabled||b.type==='download'&&!policy.evidence.captureDownloads||b.type==='interaction'&&!policy.evidence.captureInteractions)return res.status(403).json({error:'Evidence collection is disabled by current policy'});
const payload=(b.type==='interaction'?InteractionEvidenceSchema:payloads[b.type]).parse(b.payload);if(includesCredential(payload))return res.status(400).json({error:'Recognizable credentials are excluded from evidence'});plain=Buffer.from(JSON.stringify(payload));if(plain.length>65536)return res.status(413).json({error:'Evidence too large'});
const source=db.prepare('SELECT id,kind,metadata FROM events WHERE device_id=? AND json_extract(metadata,\'$.eventId\')=?').get(req.device.id,b.eventId);if(!source)return res.status(404).json({error:'Own source event not found'});
const matching=b.type==='download'?['download.scan','download.released','download.block']:['clipboard.copy','clipboard.cut','clipboard.paste','clipboard.block','agent.action'];if(!matching.includes(source.kind))return res.status(400).json({error:'Evidence type does not match source event'});
const matching=b.type==='download'?['download.scan','download.released','download.block']:['clipboard.copy','clipboard.cut','clipboard.paste','agent.action'];if(!matching.includes(source.kind))return res.status(400).json({error:'Evidence type does not match source event'});
if(!sourceMatches(source,payload,b.type))return res.status(400).json({error:'Evidence facts contradict the source event'});
const prior=db.prepare('SELECT * FROM protected_evidence WHERE source_event_id=? AND type=?').get(source.id,b.type);if(prior){
let same=false;try{if(prior.key_id===keyId){const priorPlain=decryptStoredEvidence(prior,key);try{same=JSON.stringify(JSON.parse(priorPlain.toString('utf8')))===JSON.stringify(payload);}finally{priorPlain.fill(0);}}}catch{}
@@ -87,7 +94,7 @@ export function installEvidence(app,store,config,device,admin){
app.get('/api/admin/evidence/:id',admin,enabled,(req,res)=>{
const id=z.string().uuid().parse(req.params.id),reason=z.enum(['incident-response','investigation','compliance']).parse(req.query.reason),row=db.prepare('SELECT * FROM protected_evidence WHERE id=?').get(id);if(!row)return res.status(404).json({error:'Evidence not found'});
const incident=db.prepare("SELECT id FROM incident_cases WHERE event_id=? AND status IN ('open','investigating')").get(row.source_event_id);if(row.expires_at<=new Date().toISOString()&&!incident)return res.status(410).json({error:'Evidence access expired'});if(row.key_id!==keyId)return res.status(503).json({error:'Evidence key unavailable'});
let payload;try{const plain=decryptStoredEvidence(row,key);try{payload=payloads[row.type].parse(JSON.parse(plain.toString('utf8')));}finally{plain.fill(0);}}catch{return res.status(500).json({error:'Evidence integrity verification failed'});}
let payload;try{const plain=decryptStoredEvidence(row,key);try{payload=(row.type==='interaction'?z.union([InteractionEvidenceSchema,payloads.interaction]):payloads[row.type]).parse(JSON.parse(plain.toString('utf8')));}finally{plain.fill(0);}}catch{return res.status(500).json({error:'Evidence integrity verification failed'});}
// Access must be durably attributable before plaintext leaves the service.
store.event(row.device_id,'evidence.accessed',{actor:req.principal.sub,evidenceId:row.id,sourceEventId:row.source_event_id,evidenceType:row.type,reason});
res.json({id:row.id,sourceEventId:row.source_event_id,type:row.type,createdAt:row.created_at,expiresAt:row.expires_at,incidentId:incident?.id??null,payload});
+4 -2
View File
@@ -3,14 +3,16 @@ let evidencePolicyEditable=false;
function updateEvidencePolicyControls(){
$('evidence-policy-enabled').disabled=!evidencePolicyEditable;
$('evidence-policy-downloads').disabled=!evidencePolicyEditable||!$('evidence-policy-enabled').checked;
$('evidence-policy-interactions').disabled=!evidencePolicyEditable||!$('evidence-policy-enabled').checked;
}
function fillEvidencePolicy(p){
$('evidence-policy-enabled').checked=p.evidence?.enabled===true;
$('evidence-policy-downloads').checked=p.evidence?.captureDownloads===true;
$('evidence-policy-interactions').checked=p.evidence?.captureInteractions===true;
updateEvidencePolicyControls();
}
const fillBeforeEvidencePolicy=fill;fill=function(p){fillBeforeEvidencePolicy(p);fillEvidencePolicy(p);};
const draftBeforeEvidencePolicy=draft;draft=function(){const p=draftBeforeEvidencePolicy();p.evidence={enabled:$('evidence-policy-enabled').checked,captureDownloads:$('evidence-policy-downloads').checked};return p;};
const draftBeforeEvidencePolicy=draft;draft=function(){const p=draftBeforeEvidencePolicy();p.evidence={enabled:$('evidence-policy-enabled').checked,captureDownloads:$('evidence-policy-downloads').checked,captureInteractions:$('evidence-policy-interactions').checked};return p;};
const permissionsBeforeEvidencePolicy=applyOperatorPermissions;applyOperatorPermissions=function(principal){permissionsBeforeEvidencePolicy(principal);evidencePolicyEditable=!!principal?.permissions?.includes(independentApprovalRequired?'policy.prepare':'policy.write');updateEvidencePolicyControls();};
for(const id of ['evidence-policy-enabled','evidence-policy-downloads'])$(id).addEventListener('change',()=>{updateEvidencePolicyControls();updateDraftStatus();});
for(const id of ['evidence-policy-enabled','evidence-policy-downloads','evidence-policy-interactions'])$(id).addEventListener('change',()=>{updateEvidencePolicyControls();updateDraftStatus();});
if(policy)fillEvidencePolicy(policy);if(currentPrincipal)applyOperatorPermissions(currentPrincipal);
+1 -1
View File
@@ -15,7 +15,7 @@
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><div class="wide" id="agent-grants-controls"><h3>Individual agent identities</h3><label>Identity mode<select id="agent-identity-mode"><option value="legacy">Existing shared pairing (legacy)</option><option value="individual">Individual agent grants only</option></select></label><p>Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.</p><button type="button" id="agent-grant-add" class="secondary">Add agent grant</button><p id="agent-grants-status" role="status" aria-live="polite"></p><div id="agent-grants-editor"></div><p>Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.</p></div><details class="wide"><summary>Advanced source configuration: YARA, Sigma and visual references</summary><p class="muted">Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.</p><label>Security policy JSON<textarea id="security-json" rows="14"></textarea></label><label>Local agent policy JSON<textarea id="agents-json" rows="6" readonly></textarea></label></details><label>AI provider<select id="ai-provider"><optiLine truncated
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.</p><p>Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</label><label><input id="clipboard-cut" type="checkbox"> Allow cut</label><label><input id="clipboard-paste" type="checkbox"> Allow paste</label><label><input id="clipboard-command" type="checkbox"> Block suspicious execution commands</label></div><label>Maximum clipboard text bytes<input id="clipboard-max" type="number" min="1024" max="1048576"></label><p>Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.</p></section><section id="devices" class="card"><div class="section-head"><div><p class="eyebrow">DEVICE FLEET</p><h2>Enrollment and access</h2><p>Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.</p></div></div><div class="row"><input id="enroll-user" placeholder="Employee identity / asset owner"><button id="enroll">Create one-use enrollment code</button></div><output id="enrollment"></output><div class="row filter-row"><label>Find a device<input id="device-filter" type="search" placeholder="Name, identity or platform"></label><label>Access<select id="device-access-filter"><option value="all">All devices</option><option value="active">Active</option><option value="revoked">Revoked</Line truncated
<section id="access-sessions" class="card"><p class="eyebrow">ADMINISTRATOR ACCESS</p><h2>Active management sessions</h2><p>Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.</p><button id="sessions-load" class="secondary">Refresh active sessions</button><p id="sessions-status" role="status" aria-live="polite">Session inventory requires the administrator role.</p><div class="table-wrap"><table><thead><tr><th>Identity</th><th>Role</th><th>Expires</th><th>Action</th></tr></thead><tbody id="session-rows"></tbody></table></div></section><section id="incidents" class="card"><p class="eyebrow">SECURITY OPERATIONS</p><h2>Incident triage</h2><p>Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.</p><div class="row filter-row"><label>Status<select id="incident-status-filter"><option value="">All statuses</option><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity-filter"><option value="">All severities</option><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><button id="incidents-refresh" class="secondary">Refresh incidents</button></div><p id="incidents-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Created</th><th>Security event</th><th>Status</th><th>Severity</th><th>Assignee</th><th>Review</th></tr></thead><tbody id="incident-rows"></tbody></table></div><button id="incidents-more" class="secondary" hidden>Load older incidents</button><div id="incident-detail" hidden><div class="section-head"><h3 id="incident-title">Incident</h3><button id="incident-detail-refresh" class="secondary">Discard edits and refresh</button></div><p id="incident-revision" class="muted"></p><pre id="incident-event"></pre><section id="incident-evidence" class="evidence-viewer" hidden aria-labelledby="evidence-title"><div class="section-head"><h3 id="evidence-title">Device-reported protected evidence</h3><button id="evidence-close" class="secondary">Close and clear</button></div><label>Access purpose<select id="evidence-reason"><option value="">Choose a purpose</option><option value="incident-response">Incident response</option><option value="investigation">Investigation</option><option value="compliance">Compliance</option></select></label><div id="evidence-rows"></div><p id="evidence-status" role="status" aria-live="polite"></p><pre id="evidence-plaintext" hidden></pre></section><div class="grid"><label>Investigation status<select id="incident-state"><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity"><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><label>Assignee<input id="incident-assignee" maxlength="160" placeholder="Team or analyst identity"></label><label class="wide">Investigation note<textarea id="incident-comment" maxlength="1000" placeholder="Metadata and investigation outcome only; do not paste secrets."></textarea></label></div><button id="incident-save">Save investigation update</button><p id="incident-detail-status" role="status" aria-live="polite"></p><h3>Investigation history</h3><div id="incident-history"></div></div></section><section class="card" id="audit-retention"><p class="eyebrow">AUDIT LIFECYCLE</p><h2>Database event retention</h2><p id="audit-retention-status" role="status">Loading deployment configuration.</p><p class="muted">Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.</p></section><section id="events" class="card"><p class="eyebrow">SECURITY MONITORING</p><h2>Recent audit events</h2><div class="row"><button id="export-ecs" class="secondary">Export ECS JSON</button><button id="export-cef" class="secondary">Export CEF</button></div><div class="row filter-row"><label>Find an event<input id="event-filter" type="search" placeholder="Event type, device or metadata"></label><label>Decision<select id="event-decision-filter"><option value="all">All events</option><option value="block">Blocks</option><option value="allow">Allows</option></select></label><output id="event-filter-count" aria-live="polite"></output></div><div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Device</th><th>Metadata</th></tr></thead><tbody id="event-rows"></tbody></table></div><p class="muted">This view shows the latest 100 eventsLine truncated
<section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Download evidence collection</h2><p>Opt in through centrally signed policy. Both controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label></div><p>Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.</p><p>Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. This setting does not enable automatic interaction or clipboard-content capture. Publish and review this draft through the normal policy workflow.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diagnostics</label><label>Mode<select id="diagnostics-mode"><option value="operational">Operational</option><option value="debug">Detailed debug</option></select></label><label class="wide">Collector endpoint<input id="diagnostics-endpoint" type="url" placeholder="https://collector.company.example"></label><label>Operational sampling<input id="diagnostics-sample" type="number" min="0" max="1" step="0.01"></label><label>Debug sampling<input id="diagnostics-debug-sample" type="number" min="0" max="1" step="0.01"></label><label>Maximum queued records<input id="diagnostics-qLine truncated
<section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Protected evidence collection</h2><p>Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label><label><input id="evidence-policy-interactions" type="checkbox"> Capture confirmed managed clipboard and agent-click metadata</label></div><p>Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.</p><p>Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, completed action and bounded target metadata. Page paths, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Physical user clicks and submissions are not automatically captured by this increment. Publish and review this draft through the normal policy workflow.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diagnostics</label><label>Mode<select id="diagnostics-mode"><option value="operational">Operational</option><option value="debug">Detailed debug</option></select></label><label class="wide">Collector endpoint<Line truncated