diff --git a/AGENTS.md b/AGENTS.md
index ba02a66..cbd5225 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,6 +25,11 @@ debugging, optimization and feature work, not merely implemented and left idle.
- Check collector `storedBytes` and exporter failures before long debug sessions.
The local protected receiver has a finite storage budget; missing exports or
rejected writes must not be mistaken for a successful observability proof.
+- Check diagnostics `lifetimeFailedExports` as well as the current generation's
+ `failedExports`; disable/reconfiguration must not erase evidence of earlier
+ failures within the same browser process. Pre-enrollment startup has no signed
+ export authorization: use bounded local startup snapshots, and do not infer a
+ timeout's cause from later successful traces.
- Do not hide failed tests, weaken enforcement, or repeat a protected operation
because telemetry failed. Diagnostic traces are distinct from durable security
audit and encrypted forensic evidence.
diff --git a/README.md b/README.md
index 1d06215..4a1bf82 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ S Browser uses translucent materials, rounded controls, light/dark themes, and a
| Integrations | OIDC SSO; administrator/operator/auditor roles; system/fixed/PAC proxies; managed DNS; optional Windows VPN presence checks; ICAP/ICAPS inspection. |
| Administration | Policy templates, diffs and history, independent publication review, groups, profiles, teaching workflows, incidents and configurable event retention. |
| AI and agents | Approved AI routing with server-held credentials; scoped local agent navigation, tabs, page interaction, settings and bookmarks. Separate centrally approved agent identities, scoped credentials, expiry, generation rotation and targeted revocation; administrators can disable all agent access. |
-| Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Protected download metadata evidence and governed analyst access; automatic interaction/content evidence remains incomplete. |
+| Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Governed protected download metadata and opt-in completed clipboard/agent-click metadata; general interaction and content recording remain incomplete. |
| Additional controls | Local encrypted password vault with explicit autofill; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. |
Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md).
@@ -189,4 +189,4 @@ A project distribution license has not yet been selected. Third-party components
Security audit verification, optional external ML-DSA-87 signing and retention proof details are documented in [Audit integrity](docs/audit-integrity.md). Independent checkpoint delivery and production signing-key rotation remain under development.
-Centrally signed, default-off [automatic download evidence](docs/automatic-download-evidence.md) records protected file metadata without storing document bodies. Evidence upload uses a [dedicated PQ channel](docs/evidence-pq-channel.md); native content/interaction capture, device identity and production key custody remain under development.
+Centrally signed, default-off [automatic download evidence](docs/automatic-download-evidence.md) records protected file metadata without storing document bodies. [Interaction evidence](docs/interaction-evidence.md) covers completed guarded copy/cut/paste and agent clicks using bounded metadata. Evidence upload uses a [dedicated PQ channel](docs/evidence-pq-channel.md); broader content/interaction capture, device identity and production key custody remain under development.
diff --git a/apps/browser/agent-adapters.cjs b/apps/browser/agent-adapters.cjs
index d90fd87..176357e 100644
--- a/apps/browser/agent-adapters.cjs
+++ b/apps/browser/agent-adapters.cjs
@@ -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'),
diff --git a/apps/browser/clipboard-dom.cjs b/apps/browser/clipboard-dom.cjs
index a46e8de..4b6af3b 100644
--- a/apps/browser/clipboard-dom.cjs
+++ b/apps/browser/clipboard-dom.cjs
@@ -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');
diff --git a/apps/browser/clipboard-guard.cjs b/apps/browser/clipboard-guard.cjs
index a718739..7a53775 100644
--- a/apps/browser/clipboard-guard.cjs
+++ b/apps/browser/clipboard-guard.cjs
@@ -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);})]);}
diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs
index 74fdcd2..9497def 100644
--- a/apps/browser/diagnostics.cjs
+++ b/apps/browser/diagnostics.cjs
@@ -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};
diff --git a/apps/browser/evidence-capture.cjs b/apps/browser/evidence-capture.cjs
index b6be7d8..a7561b4 100644
--- a/apps/browser/evidence-capture.cjs
+++ b/apps/browser/evidence-capture.cjs
@@ -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};
diff --git a/apps/browser/evidence-delivery.cjs b/apps/browser/evidence-delivery.cjs
index ffdc79b..488ef4e 100644
--- a/apps/browser/evidence-delivery.cjs
+++ b/apps/browser/evidence-delivery.cjs
@@ -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};
diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs
index 10ed007..d198bac 100644
--- a/apps/browser/main.cjs
+++ b/apps/browser/main.cjs
@@ -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&§ionAllowed(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};});
diff --git a/apps/browser/management-channel.cjs b/apps/browser/management-channel.cjs
index 9782945..49cbf93 100644
--- a/apps/browser/management-channel.cjs
+++ b/apps/browser/management-channel.cjs
@@ -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};
diff --git a/apps/server/app.mjs b/apps/server/app.mjs
index f4db85d..2e4e950 100644
--- a/apps/server/app.mjs
+++ b/apps/server/app.mjs
@@ -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'});
diff --git a/apps/server/evidence.mjs b/apps/server/evidence.mjs
index d8b1f81..7a79767 100644
--- a/apps/server/evidence.mjs
+++ b/apps/server/evidence.mjs
@@ -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});
diff --git a/apps/server/public/evidence-policy-admin.js b/apps/server/public/evidence-policy-admin.js
index 7823810..286da68 100644
--- a/apps/server/public/evidence-policy-admin.js
+++ b/apps/server/public/evidence-policy-admin.js
@@ -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);
diff --git a/apps/server/public/index.html b/apps/server/public/index.html
index 61d5a7e..dafcef9 100644
--- a/apps/server/public/index.html
+++ b/apps/server/public/index.html
@@ -15,7 +15,7 @@
Enterprise security and local agents
Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.
Individual agent identities
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.
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.
Advanced source configuration: YARA, Sigma and visual references
Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.
Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.
Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.
Governed document teaching
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.
Local password vault
Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.
Publish signed policy to apply these controls.
Visual impersonation protection
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.
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.
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.
Managed clipboard and ClickFix protection
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.
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.
DEVICE FLEET
Enrollment and access
Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.
Device
Platform
Identity
Policy
Last seen
Access
ADMINISTRATOR ACCESS
Active management sessions
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.
Session inventory requires the administrator role.
Identity
Role
Expires
Action
SECURITY OPERATIONS
Incident triage
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.
Created
Security event
Status
Severity
Assignee
Review
Incident
Device-reported protected evidence
Investigation history
AUDIT LIFECYCLE
Database event retention
Loading deployment configuration.
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.
SECURITY MONITORING
Recent audit events
Time
Event
Device
Metadata
This view shows the latest 100 events. Prompts and document contents are excluded. Exports contain up to 1,000 events; cursor-based APIs and durable HTTPS delivery support continuous SIEM ingestion.
-
COMPANY EXPERIENCE
Company logo and start page
Roll out your company identity through signed policy. Name and accent above apply alongside this logo.
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.
Download inspection
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.
AUDIT INTEGRITY
Verify security audit records
Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.
Not verified in this session.
PROTECTED SECURITY EVIDENCE
Download evidence collection
Opt in through centrally signed policy. Both controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.
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.
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.
OBSERVABILITY
Browser OpenTelemetry
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.
HTTPS is required except for a numeric loopback collector. Queue and export limits remain enforced. Browser logs and traces use OTLP. Durable security events are separately delivered by the management service in ECS or CEF; its HTTPS destination, TLS certificates and credentials are configured by the server operator, not exposed in browser policy. Backend service traces use the server OpenTelemetry configuration. Endpoint credentials do not belong in policy URLs. Disable and publish to stop new collection.