Implement managed browser security, individual agents and observability
This commit is contained in:
1 parent
ff482ebe23
commit
ea448de2d4
172 files changed
+5404
-347
No files matched your search
@@ -3,12 +3,13 @@ function domAction(mode, selector, text, expectedUrl) {
|
||||
if (location.href !== expectedUrl) throw Error('Document changed before action');
|
||||
const visible = e => {
|
||||
if (!e || e.closest('[hidden],[aria-hidden="true"],script,style,template,noscript')) return false;
|
||||
const style = getComputedStyle(e);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && e.getClientRects().length > 0;
|
||||
for (let node=e; node; node=node.parentElement) { const style=getComputedStyle(node); if(style.display==='none'||style.visibility==='hidden'||style.opacity==='0') return false; }
|
||||
return e.isConnected !== false && e.getClientRects().length > 0;
|
||||
};
|
||||
const sensitive = e => {
|
||||
const forbidden = /password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|credit.?card|cc-number|cc-csc/i;
|
||||
for (let node = e; node && node !== document.body; node = node.parentElement) {
|
||||
for (let node = e; node; node = node.parentElement) {
|
||||
if (node.getAttribute('data-sensitive') !== null && node.getAttribute('data-sensitive') !== undefined) return true;
|
||||
if (['name','id','autocomplete','aria-label','data-sensitive','action'].some(a => forbidden.test(node.getAttribute(a) || ''))) return true;
|
||||
}
|
||||
return /^(password|file|hidden)$/i.test(e.type || '');
|
||||
@@ -24,8 +25,32 @@ function domAction(mode, selector, text, expectedUrl) {
|
||||
}
|
||||
return { text: parts.join('\n').slice(0,10000) };
|
||||
}
|
||||
if (['select','check'].includes(mode) && document.querySelectorAll(selector).length !== 1) throw Error('Target is ambiguous');
|
||||
const e = document.querySelector(selector);
|
||||
if (!visible(e) || sensitive(e) || e.disabled || e.getAttribute('aria-disabled') === 'true') throw Error('Target is unavailable or sensitive');
|
||||
if (!visible(e) || sensitive(e) || e.disabled || e.closest('[disabled],[aria-disabled="true"],[inert]') || e.getAttribute('aria-disabled') === 'true') throw Error('Target is unavailable or sensitive');
|
||||
if (mode === 'select') {
|
||||
if (e.tagName !== 'SELECT' || e.multiple) throw Error('Target cannot be selected');
|
||||
const optionUnavailable = option => {
|
||||
if (option.disabled || option.parentElement?.disabled || sensitive(option)) return true;
|
||||
for(let node=option;node&&node!==e;node=node.parentElement) {
|
||||
if(node.hidden || node.getAttribute('aria-hidden')==='true' || node.getAttribute('aria-disabled')==='true') return true;
|
||||
const style=getComputedStyle(node);if(style.display==='none'||style.visibility==='hidden'||style.opacity==='0') return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const matches = Array.from(e.options).filter(option => option.value === text);
|
||||
if (matches.length !== 1 || optionUnavailable(matches[0])) throw Error('Option is unavailable or ambiguous');
|
||||
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value').set.call(e,text);
|
||||
e.dispatchEvent(new Event('input',{bubbles:true})); e.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
if (location.href !== expectedUrl || e.value !== text || !visible(e) || sensitive(e) || e.disabled || e.closest('[disabled],[aria-disabled="true"],[inert]') || optionUnavailable(matches[0]) || !Array.from(e.options).includes(matches[0]) || Array.from(e.options).filter(option=>option.value===text).length!==1) throw Error('Target changed during action');
|
||||
return {completed:true};
|
||||
}
|
||||
if (mode === 'check') {
|
||||
if (e.tagName !== 'INPUT' || e.type !== 'checkbox' || typeof text !== 'boolean') throw Error('Target cannot be checked');
|
||||
if (e.checked !== text) e.click();
|
||||
if (location.href !== expectedUrl || e.checked !== text || !visible(e) || sensitive(e) || e.disabled || e.closest('[disabled],[aria-disabled="true"],[inert]')) throw Error('Target changed during action');
|
||||
return {completed:true};
|
||||
}
|
||||
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};
|
||||
@@ -40,23 +65,57 @@ function domAction(mode, selector, text, expectedUrl) {
|
||||
throw Error('Unsupported DOM action');
|
||||
}
|
||||
const secret = text => /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,})\b|\b(?:password|passwd|api[_ -]?key|access[_ -]?token|client[_ -]?secret)\s*[:=]\s*["']?[^\s"']{4,}/i.test(text);
|
||||
const script = (mode,args,url) => `(${domAction.toString()})(${JSON.stringify(mode)},${JSON.stringify(args.selector || '')},${JSON.stringify(args.text || '')},${JSON.stringify(url)})`;
|
||||
// Fixed inspection code: no caller-supplied JavaScript, credential fields or hidden DOM.
|
||||
function inspectControls(expectedUrl) {
|
||||
if(location.href!==expectedUrl)throw Error('Document changed before action');
|
||||
const controls=[];
|
||||
for(const e of document.querySelectorAll('a,button,input,textarea,select')){
|
||||
if(controls.length>=100)break;
|
||||
const forbidden=/password|passwd|secret|token|credential|api.?key|auth|one.?time|otp|credit.?card|cc-number|cc-csc/i;
|
||||
let sensitive=false;
|
||||
for(let node=e;node;node=node.parentElement)if((node.getAttribute('data-sensitive')!==null&&node.getAttribute('data-sensitive')!==undefined)||['name','id','autocomplete','aria-label','data-sensitive','action'].some(a=>forbidden.test(node.getAttribute(a)||'')))sensitive=true;
|
||||
const style=getComputedStyle(e);
|
||||
if(sensitive||/^(password|file|hidden)$/i.test(e.type||'')||e.closest('[hidden],[aria-hidden="true"]')||!e.getClientRects().length||style.display==='none'||style.visibility==='hidden'||style.opacity==='0')continue;
|
||||
let selector='';
|
||||
if(/^[a-zA-Z_][a-zA-Z0-9_-]{0,100}$/.test(e.id||'')&&document.querySelectorAll('#'+e.id).length===1)selector='#'+e.id;
|
||||
else if(/^[a-zA-Z0-9_-]{1,100}$/.test(e.getAttribute('name')||'')){const candidate=e.tagName.toLowerCase()+'[name="'+e.getAttribute('name')+'"]';if(document.querySelectorAll(candidate).length===1)selector=candidate;}
|
||||
const row={selector,role:e.tagName.toLowerCase(),label:(e.getAttribute('aria-label')||'').slice(0,160),text:(e.innerText||'').slice(0,300),value:typeof e.value==='string'?e.value.slice(0,300):''};
|
||||
if(e.tagName==='INPUT'&&e.type==='checkbox')row.checked=e.checked===true;
|
||||
if(e.tagName==='SELECT') {
|
||||
row.options=[];
|
||||
for(const option of Array.from(e.options)) {
|
||||
if(row.options.length>=20)break;
|
||||
let excluded=false;
|
||||
for(let node=option;node;node=node.parentElement) {
|
||||
if(node.hidden||node.getAttribute('aria-hidden')==='true'||(node.getAttribute('data-sensitive')!==null&&node.getAttribute('data-sensitive')!==undefined)||['name','id','autocomplete','aria-label','action'].some(a=>forbidden.test(node.getAttribute(a)||'')))excluded=true;
|
||||
const style=getComputedStyle(node);if(style.display==='none'||style.visibility==='hidden'||style.opacity==='0')excluded=true;
|
||||
}
|
||||
if(!excluded&&String(option.value).length<=128)row.options.push({value:String(option.value),label:String(option.label||option.textContent||'').slice(0,160),disabled:option.disabled===true||option.parentElement?.disabled===true});
|
||||
}
|
||||
}
|
||||
controls.push(row);
|
||||
}
|
||||
return {text:"",controls};
|
||||
}
|
||||
function scrollPage(expectedUrl,x,y){if(location.href!==expectedUrl)throw Error('Document changed before action');window.scrollTo({left:x,top:y,behavior:'instant'});return {completed:true};}
|
||||
const script = (mode,args,url) => `(${domAction.toString()})(${JSON.stringify(mode)},${JSON.stringify(args.selector || '')},${JSON.stringify(mode==='check'?args.checked:mode==='select'?args.value:args.text || '')},${JSON.stringify(url)})`;
|
||||
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,ai,inspect,audit,appControls={}} = {}) {
|
||||
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,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);
|
||||
if (signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.webContents || c.webContents.isDestroyed?.() || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
|
||||
if (!c.sessionId || !c.token || (args.sessionId !== undefined && args.sessionId !== c.sessionId)) throw Error('Unknown managed session');
|
||||
if (['click','type'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable');
|
||||
if (['click','type','scroll','select','check','back','forward','reload'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable');
|
||||
const url = c.webContents.getURL();
|
||||
if (typeof c.destinationAllowed!=='function' || (action!=='navigate' && (!/^https?:\/\//.test(url) || c.destinationAllowed(url,c.policy)!==true))) throw Error('Session destination blocked');
|
||||
const changesDestination=['navigate','back','forward'].includes(action);
|
||||
if (typeof c.destinationAllowed!=='function' || (!changesDestination && (!/^https?:\/\//.test(url) || c.destinationAllowed(url,c.policy)!==true))) throw Error('Session destination blocked');
|
||||
const agents=c.policy.agents;
|
||||
if (!Array.isArray(agents.scopes) || !agents.scopes.includes(action)) throw Error('Agent scope denied');
|
||||
if (!Array.isArray(agents.allowedHosts) || (action!=='navigate' && agents.allowedHosts.length && !agents.allowedHosts.some(p=>hostMatches(new URL(url).hostname,p)))) throw Error('Agent destination denied');
|
||||
if (!Array.isArray(agents.allowedHosts) || (!changesDestination && agents.allowedHosts.length && !agents.allowedHosts.some(p=>hostMatches(new URL(url).hostname,p)))) throw Error('Agent destination denied');
|
||||
return {...c,url,action,version:c.policyVersion ?? c.policy.version};
|
||||
};
|
||||
const check = (initial,args,signal,expectedUrl=initial.url) => {
|
||||
@@ -78,12 +137,27 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,ai,
|
||||
const initial = snapshot(args,signal,mode);
|
||||
if (mode!=='read' && (typeof args.selector!=='string' || args.selector.length>512 || !/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(args.selector))) throw Error('Invalid selector');
|
||||
if (mode==='type' && (typeof args.text!=='string' || args.text.length>16384)) throw Error('Invalid text');
|
||||
if (mode==='select' && (typeof args.value!=='string' || args.value.length>16384)) throw Error('Invalid value');
|
||||
if (mode==='check' && typeof args.checked!=='boolean') throw Error('Invalid checked state');
|
||||
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)};
|
||||
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'];
|
||||
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'];
|
||||
// Native history owns target resolution and pre-release destination checks. A
|
||||
// refreshed token permits intentional navigation but never a different tab.
|
||||
const historyAdapters=Object.fromEntries(['back','forward','reload'].map(action=>[action,async(args={}, {signal}={})=>{
|
||||
const initial=snapshot(args,signal,action);
|
||||
if(typeof history!=='function')throw Error('Managed session unavailable');
|
||||
const result=await history(action,initial,{signal}), refreshed=result?.context;
|
||||
if(result?.completed!==true || !refreshed || refreshed.sessionId!==initial.sessionId || refreshed.webContents!==initial.webContents || (refreshed.policyVersion??refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');
|
||||
const url=refreshed.webContents.getURL();
|
||||
let parsed;try{parsed=new URL(url);}catch{throw Error('Destination blocked');}
|
||||
if(!['http:','https:'].includes(parsed.protocol)||parsed.username||parsed.password||refreshed.destinationAllowed?.(url,refreshed.policy)!==true||(refreshed.policy?.agents?.allowedHosts?.length&&!refreshed.policy.agents.allowedHosts.some(p=>hostMatches(parsed.hostname,p))))throw Error('Destination blocked');
|
||||
check({...refreshed,action,version:initial.version,url},args,signal,url);
|
||||
return {completed:true};
|
||||
}]));
|
||||
const appSnapshot=action=>{const c=getContext();if(!c?.ready||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||(c.policy.expiresAt&&Date.parse(c.policy.expiresAt)<=Date.now()))throw Error('Agent scope denied');return c;};
|
||||
const appAdapters=Object.fromEntries(appActions.map(action=>[action,async(args={}, {signal}={})=>{
|
||||
const initial=appSnapshot(action),version=initial.policyVersion??initial.policy.version;
|
||||
@@ -91,6 +165,10 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,ai,
|
||||
if(signal?.aborted||typeof appControls[action]!=='function')throw Error('Managed session unavailable');
|
||||
if(action==='tabs:create'&&args.url){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('Agent destination denied');}
|
||||
current();const result=await appControls[action](args,{signal,policyVersion:version});current();
|
||||
if(action==='library:organize-plan'){
|
||||
const labels=[...(result?.plan?.bookmarks||[]).map(row=>row.folder),...(result?.plan?.tabs||[]).map(row=>row.group)].join('\n');
|
||||
if(labels.length>16000||secret(labels))throw Error('Sensitive content cannot be returned');const verdict=await inspect(labels,{...initial,action});current();if(verdict?.decision!=='allow')throw Error('Company policy blocks content access');
|
||||
}
|
||||
if(action==='bookmarks:list'){
|
||||
const entries=[...(result?.managed||[]),...(result?.personal?.items||[])];
|
||||
for(const item of entries){const text=[item.title,item.url,item.folder].filter(v=>typeof v==='string').join('\n');if(text.length>10000||secret(text))throw Error('Sensitive content cannot be returned');const verdict=await inspect(text,{...initial,action});current();if(verdict?.decision!=='allow')throw Error('Company policy blocks content access');}
|
||||
@@ -99,13 +177,16 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,ai,
|
||||
}]));
|
||||
return {
|
||||
...appAdapters,
|
||||
...historyAdapters,
|
||||
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}));
|
||||
},
|
||||
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'),
|
||||
read:dom('read'),click:dom('click'),type:dom('type'),select:dom('select'),check:dom('check'),
|
||||
inspect:async(args,{signal}={})=>{const initial=snapshot(args,signal,'inspect');const result=await initial.webContents.executeJavaScript(`(${inspectControls.toString()})(${JSON.stringify(initial.url)})`,false);check(initial,args,signal);if(!Array.isArray(result?.controls)||result.controls.length>100)throw Error('Invalid text response');const text=JSON.stringify(result);if(text.length>10000)throw Error('Sensitive content cannot be returned');await classify(text,initial,args,signal,initial.policy.agents.allowSensitiveRead===true);return result;},
|
||||
scroll:async(args,{signal}={})=>{const initial=snapshot(args,signal,'scroll');if(![args.x,args.y].every(n=>Number.isSafeInteger(n)&&n>=0&&n<=10000000))throw Error('Invalid scroll position');const result=await initial.webContents.executeJavaScript(`(${scrollPage.toString()})(${JSON.stringify(initial.url)},${args.x},${args.y})`,false);check(initial,args,signal);return {completed:result?.completed===true};},
|
||||
ai:async(args,{signal}={})=>{const initial=snapshot(args,signal,'ai');if(typeof args.prompt!=='string'||args.prompt.length>16384)throw Error('Invalid prompt');const result=await ai(args.prompt,{signal});check(initial,args,signal);const text=typeof result==='string'?result:result?.text ?? result?.answer;return {text:await classify(text,initial,args,signal,false)};}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ async function runClient(connectionFile,requestFile){
|
||||
if(origin.protocol!=='http:'||origin.hostname!=='127.0.0.1'||origin.username||origin.password||origin.pathname!=='/'||origin.search||origin.hash)throw Error('Agent endpoint rejected');
|
||||
stage='request-file';const info=fs.statSync(requestFile);if(!info.isFile()||info.size>32768)throw Error('Request too large');
|
||||
const body=fs.readFileSync(requestFile);if(body.length>32768)throw Error('Request too large');JSON.parse(body.toString());
|
||||
stage='transport';const response=await require('./loopback-http.cjs').postLoopback({port:Number(origin.port),route:'/v1/actions',token:connection.token,body:JSON.parse(body.toString()),signal:AbortSignal.timeout(15000)});
|
||||
stage='transport';const response=await require('./agent-transport-client.cjs').requestAgent(connection,JSON.parse(body.toString()));
|
||||
stage='policy-response';
|
||||
const result=response.body;if(response.status!==200)throw Error('Agent policy denied action');
|
||||
const result=await response.json();if(response.status!==200)throw Error('Agent policy denied action');
|
||||
process.stdout.write(JSON.stringify(result)+'\n');app.exit(0);
|
||||
}catch(error){error.clientStage=stage;throw error;}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
'use strict';
|
||||
const http = require('node:http');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const channel=require('../../packages/core/policy-channel.cjs');
|
||||
|
||||
const APP_ACTIONS=Object.freeze(['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group']);
|
||||
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', read: 'browser:read', click: 'browser:interact', type: 'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
|
||||
const APP_ACTIONS=Object.freeze(['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']);
|
||||
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
|
||||
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
const bounded = (value, max, name) => {
|
||||
if (typeof value !== 'string' || !value.length || value.length > max || /\u0000/.test(value)) throw fail(400, `Invalid ${name}`);
|
||||
@@ -17,7 +16,7 @@ function validate(body) {
|
||||
if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action');
|
||||
const args = body.args ?? {};
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments');
|
||||
const keys = { sessions: [], navigate: ['sessionId', 'url'], read: ['sessionId'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'] }[action];
|
||||
const keys = { sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
|
||||
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
|
||||
const clean = {};
|
||||
if (action !== 'sessions' && args.sessionId !== undefined) clean.sessionId = bounded(args.sessionId, 128, 'sessionId');
|
||||
@@ -26,11 +25,14 @@ function validate(body) {
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw fail(400, 'Unsupported URL');
|
||||
clean.url = url.href;
|
||||
}
|
||||
if (['click', 'type'].includes(action)) {
|
||||
if (['click', 'type','select','check'].includes(action)) {
|
||||
clean.selector = bounded(args.selector, 512, 'selector');
|
||||
// A small CSS subset prevents pseudo-elements, escapes and control characters.
|
||||
if (!/^[a-zA-Z0-9_#.*\[\]="' >+~-]+$/.test(clean.selector) || /password|autocomplete/i.test(clean.selector)) throw fail(400, 'Unsupported selector');
|
||||
}
|
||||
if(action==='select'){if(typeof args.value!=='string'||args.value.length>1024||/\u0000/.test(args.value))throw fail(400,'Invalid option value');clean.value=args.value;}
|
||||
if(action==='check'){if(typeof args.checked!=='boolean')throw fail(400,'Invalid checked state');clean.checked=args.checked;}
|
||||
if(action==='scroll'){for(const key of ['x','y']){if(!Number.isSafeInteger(args[key])||Math.abs(args[key])>100000)throw fail(400,'Invalid scroll');clean[key]=args[key];}}
|
||||
if (action === 'type') clean.text = bounded(args.text, 16384, 'text');
|
||||
if (action === 'ai') clean.prompt = bounded(args.prompt, 16384, 'prompt');
|
||||
if(action.startsWith('tabs:')&&action!=='tabs:create')clean.sessionId=bounded(args.sessionId,128,'sessionId');
|
||||
@@ -43,6 +45,7 @@ function validate(body) {
|
||||
clean.action=args.action;clean.expectedRevision=args.expectedRevision;clean.policyVersion=args.policyVersion;
|
||||
if(args.action==='delete')clean.id=bounded(args.id,80,'bookmark id');else{const item=args.bookmark;if(!item||typeof item!=='object'||Array.isArray(item)||Object.keys(item).some(key=>!['id','title','url','folder'].includes(key)))throw fail(400,'Invalid bookmark');const folder=item.folder===undefined?'':item.folder;if(typeof folder!=='string'||folder.length>80||/[\u0000-\u001f\u007f]/.test(folder))throw fail(400,'Invalid bookmark folder');clean.bookmark={id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder};}
|
||||
}
|
||||
if(action==='library:organize-apply'){clean.proposalId=bounded(args.proposalId,128,'proposal id');if(args.confirm!==undefined&&typeof args.confirm!=='boolean'||args.autoApply!==undefined&&typeof args.autoApply!=='boolean')throw fail(400,'Invalid organization confirmation');if(args.confirm!==undefined)clean.confirm=args.confirm;if(args.autoApply!==undefined)clean.autoApply=args.autoApply;}
|
||||
return { action, args: clean, scope: SCOPES[action], sessionId: clean.sessionId };
|
||||
}
|
||||
// Never return adapter objects wholesale: session handles and sanitized text only.
|
||||
@@ -50,11 +53,14 @@ function project(action, result) {
|
||||
if(action==='application:state')return {ready:result?.ready===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0,activeTabId:typeof result?.activeTabId==='string'?result.activeTabId.slice(0,128):null,tabCount:Number.isSafeInteger(result?.tabCount)?result.tabCount:0};
|
||||
if(action==='settings:get'||action==='settings:update'){const appearance=result?.appearance||{};return {visible:result?.visible===true,hiddenSections:Array.isArray(result?.hiddenSections)?result.hiddenSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],lockedSections:Array.isArray(result?.lockedSections)?result.lockedSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],appearance:{mode:['system','light','dark'].includes(appearance.mode)?appearance.mode:'system',accent:['blue','teal','violet'].includes(appearance.accent)?appearance.accent:'blue',tabMode:['compact','separate'].includes(appearance.tabMode)?appearance.tabMode:'separate'}};}
|
||||
if(action==='bookmarks:list'){const items=values=>{if(!Array.isArray(values)||values.length>1000)throw fail(502,'Invalid bookmarks');return values.map(item=>({id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder:typeof item.folder==='string'?item.folder.slice(0,80):''}));};return {managed:items(result?.managed||[]),personal:{revision:Number.isSafeInteger(result?.personal?.revision)?result.personal.revision:0,items:items(result?.personal?.items||[])},allowUserAdd:result?.allowUserAdd===true,allowUserEdit:result?.allowUserEdit===true,allowUserDelete:result?.allowUserDelete===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0};}
|
||||
if(action==='library:organize-plan'){const plan=result?.plan;if(!plan||!Array.isArray(plan.bookmarks)||!Array.isArray(plan.tabs)||plan.bookmarks.length>100||plan.tabs.length>100)throw fail(502,'Invalid organization plan');return {proposalId:bounded(result.proposalId,128,'proposal id'),plan:{bookmarks:plan.bookmarks.map(row=>({id:bounded(row.id,80,'bookmark id'),folder:typeof row.folder==='string'?row.folder.slice(0,80):''})),tabs:plan.tabs.map(row=>({id:bounded(row.id,80,'tab id'),group:typeof row.group==='string'?row.group.slice(0,40):''}))},expiresAt:typeof result.expiresAt==='string'&&Number.isFinite(Date.parse(result.expiresAt))?result.expiresAt.slice(0,32):null};}
|
||||
if(action==='library:organize-apply')return {completed:result?.completed===true};
|
||||
if(action.startsWith('tabs:')||action==='bookmarks:mutate')return {completed:result?.completed===true,...(typeof result?.sessionId==='string'?{sessionId:bounded(result.sessionId,128,'session id')}:{})};
|
||||
if (action === 'sessions') {
|
||||
if (!Array.isArray(result) || result.length > 100) throw fail(502, 'Invalid session response');
|
||||
return result.map(s => ({ id: bounded(s.id, 128, 'session id'), ...(typeof s.title === 'string' ? { title: s.title.slice(0, 256) } : {}), ...(typeof s.foreground === 'boolean' ? {foreground:s.foreground} : {}) }));
|
||||
}
|
||||
if(action==='inspect'){if(!result||typeof result.text!=='string'||!Array.isArray(result.controls)||result.controls.length>100)throw fail(502,'Invalid inspection');const value={text:result.text,controls:result.controls.map(row=>{const item=Object.fromEntries(['selector','role','label','value','text'].filter(key=>typeof row[key]==='string').map(key=>[key,row[key]]));if(typeof row.checked==='boolean')item.checked=row.checked;if(row.options!==undefined){if(!Array.isArray(row.options)||row.options.length>20)throw fail(502,'Invalid inspection options');item.options=row.options.map(option=>{if(typeof option.value!=='string'||option.value.length>128||typeof option.label!=='string'||option.label.length>160||typeof option.disabled!=='boolean')throw fail(502,'Invalid inspection option');return {value:option.value,label:option.label,disabled:option.disabled};});}return item;})};if(JSON.stringify(value).length>65536)throw fail(502,'Invalid inspection');return value;}
|
||||
if (action === 'read' || action === 'ai') {
|
||||
if (typeof result?.text !== 'string' || result.text.length > 65536) throw fail(502, 'Invalid text response');
|
||||
return { text: result.text };
|
||||
@@ -62,70 +68,110 @@ function project(action, result) {
|
||||
return { completed: result?.completed !== false };
|
||||
}
|
||||
|
||||
async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, timeoutMs = 10000, maxRequestsPerMinute = 60 } = {}) {
|
||||
async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, traceOperation = async (_name,fn)=>fn(), timeoutMs = 10000, maxRequestsPerMinute = 60, principalMode = 'legacy', principals = [] } = {}) {
|
||||
if(credentialFile!==undefined)throw new TypeError('Plaintext pairing files are prohibited; use protected persistCredential');
|
||||
if (typeof authorize !== 'function' || typeof audit !== 'function' || !actions) throw new TypeError('authorize, actions and audit are required');
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 10 || timeoutMs > 60000 || !Number.isInteger(maxRequestsPerMinute) || maxRequestsPerMinute < 1 || maxRequestsPerMinute > 1000) throw new TypeError('Invalid limits');
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
const expected = Buffer.from(`Bearer ${token}`);
|
||||
if(!['legacy','individual'].includes(principalMode))throw new TypeError('Invalid principal mode');
|
||||
const token = principalMode==='legacy'?crypto.randomBytes(32).toString('base64url'):undefined;
|
||||
const serviceId=crypto.randomUUID(), principalRecords=new Map(), approved=new Map(), owners=new Map(), generationFloor=new Map();
|
||||
const validGrant=g=>g&&/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(g.id)&&Number.isSafeInteger(g.generation)&&g.generation>=1&&Number.isFinite(Date.parse(g.expiresAt));
|
||||
const principalLive=p=>p&&principalRecords.get(p.id)===p&&approved.get(p.id)?.enabled!==false&&p.generation===approved.get(p.id)?.generation&&Date.parse(p.expiresAt)>Date.now();
|
||||
const invalidate=id=>{for(const [key,item]of challenges)if(item.principalId===id)challenges.delete(key);for(const [key,item]of proofs)if(item.principalId===id)proofs.delete(key);for(const [controller,owner]of owners)if(owner===id)controller.abort();};
|
||||
const reconcilePrincipals=grants=>{
|
||||
if(!Array.isArray(grants)||grants.length>32||grants.some(g=>!validGrant(g))||new Set(grants.map(g=>g.id)).size!==grants.length)throw new TypeError('Invalid principal grants');
|
||||
const next=new Map(grants.map(g=>{const copy=JSON.parse(JSON.stringify(g)),floor=generationFloor.get(g.id)||0;if(copy.generation<floor)copy.enabled=false;else generationFloor.set(g.id,copy.generation);return [g.id,copy];}));
|
||||
for(const [id,old]of approved){const current=next.get(id);if(!current||current.enabled===false||Date.parse(current.expiresAt)<=Date.now()||JSON.stringify(old)!==JSON.stringify(current))invalidate(id);if(!current||current.enabled===false||current.generation!==old.generation||current.expiresAt!==old.expiresAt||Date.parse(current.expiresAt)<=Date.now())principalRecords.delete(id);}
|
||||
approved.clear();for(const [id,g]of next)approved.set(id,g);
|
||||
};
|
||||
const proofs=new Map();
|
||||
const ed=crypto.generateKeyPairSync('ed25519'),pq=crypto.generateKeyPairSync('ml-dsa-87');
|
||||
const edPublic=ed.publicKey.export({type:'spki',format:'pem'}),pqPublic=pq.publicKey.export({type:'spki',format:'pem'});
|
||||
const challenges=new Map();
|
||||
reconcilePrincipals(principals);
|
||||
for(const g of principals)if(g.token&&g.enabled!==false&&Date.parse(g.expiresAt)>Date.now()){if(!/^[A-Za-z0-9_-]{43}$/.test(g.token))throw new TypeError('Invalid principal token');principalRecords.set(g.id,{id:g.id,generation:g.generation,expiresAt:g.expiresAt,token:g.token});}
|
||||
let enabled = true, windowStart = Date.now(), requests = 0;
|
||||
const running = new Set();
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const server = http.createServer(async (req, res) => traceOperation('agent.request',async()=>{
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
const reply = (status, data) => { if (!res.destroyed && !res.writableEnded) { res.writeHead(status); res.end(JSON.stringify(data)); } };
|
||||
const controller = new AbortController();
|
||||
let action, outcome = 'denied', timer;
|
||||
let principal, verifiedPrincipal, action, outcome = 'denied', timer, protectedContext, bundle, validatedRequest;
|
||||
try {
|
||||
const supplied = Buffer.from(req.headers.authorization || '');
|
||||
if (!enabled || supplied.length !== expected.length || !crypto.timingSafeEqual(supplied, expected)) throw fail(401, 'Unauthorized');
|
||||
let proof;
|
||||
if(principalMode==='individual'){
|
||||
const match=/^SafetyNet-Agent-PQ ([a-zA-Z0-9][a-zA-Z0-9._-]{0,79})\.([1-9][0-9]{0,15})\.([0-9]{13})\.([A-Za-z0-9_-]{43})\.([A-Za-z0-9_-]{64})$/.exec(req.headers.authorization||'');
|
||||
principal=match&&principalRecords.get(match[1]);if(!principalLive(principal)||principal.generation!==Number(match?.[2]))throw fail(401,'Unauthorized');
|
||||
proof=[match[0],match[3],match[4],match[5]];
|
||||
}else proof=/^SafetyNet-PQ ([0-9]{13})\.([A-Za-z0-9_-]{43})\.([A-Za-z0-9_-]{64})$/.exec(req.headers.authorization||'');
|
||||
if(!enabled||!proof||Math.abs(Date.now()-Number(proof[1]))>30000)throw fail(401,'Unauthorized');
|
||||
for(const [nonce,expiresAt] of proofs)if((typeof expiresAt==='object'?expiresAt.expiresAt:expiresAt)<Date.now())proofs.delete(nonce);
|
||||
if (req.headers.origin || req.headers['sec-fetch-site'] || !/^127\.0\.0\.1:\d+$/.test(req.headers.host || '')) throw fail(403, 'Browser-origin requests forbidden');
|
||||
if (req.method !== 'POST' || req.url !== '/v1/actions') throw fail(404, 'Not found');
|
||||
let size=0;const chunks=[];for await(const chunk of req){size+=chunk.length;if(size>131072)throw fail(413,'Request too large');chunks.push(chunk);}const wireBody=Buffer.concat(chunks);
|
||||
const digest=crypto.createHash('sha384').update(wireBody).digest('base64url');const expected=crypto.createHmac('sha384',principal?.token||token).update(principal?JSON.stringify(['safetynet-agent-auth-v1',req.url,principal.id,principal.generation,Number(proof[1]),proof[2],digest]):`${req.url}:${proof[1]}:${proof[2]}:${digest}`).digest('base64url');
|
||||
if(proofs.has(principal?`${principal.id}:${proof[2]}`:proof[2])||proofs.size>=4096||(principal&&[...proofs.values()].filter(value=>value.principalId===principal.id).length>=256)||!crypto.timingSafeEqual(Buffer.from(proof[3]),Buffer.from(expected)))throw fail(401,'Unauthorized');
|
||||
proofs.set(principal?`${principal.id}:${proof[2]}`:proof[2],principal?{principalId:principal.id,expiresAt:Number(proof[1])+30000}:Number(proof[1])+30000);if(!enabled||(principal&&!principalLive(principal)))throw fail(401,'Unauthorized');
|
||||
verifiedPrincipal=principal;
|
||||
if (req.headers.origin || req.headers['sec-fetch-site'] || !/^127\.0\.0\.1:\d+$/.test(req.headers.host || '')) throw fail(403, 'Browser-origin requests forbidden');
|
||||
if (req.method !== 'POST' || !['/v1/challenge','/v1/actions'].includes(req.url)) throw fail(404, 'Not found');
|
||||
if (!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')) throw fail(415, 'JSON required');
|
||||
if (Date.now() - windowStart >= 60000) { windowStart = Date.now(); requests = 0; }
|
||||
if (++requests > maxRequestsPerMinute) throw fail(429, 'Rate limit exceeded');
|
||||
let size = 0, chunks = [];
|
||||
for await (const chunk of req) { size += chunk.length; if (size > 32768) throw fail(413, 'Request too large'); chunks.push(chunk); }
|
||||
let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw fail(400, 'Invalid JSON'); }
|
||||
const request = validate(body); action = request.action;
|
||||
if(req.url==='/v1/challenge'){for(const [id,item] of challenges)if(item.bundle.expiresAt<=Date.now())challenges.delete(id);if(challenges.size>=64)throw fail(429,'Challenge capacity exceeded');const item=await traceOperation('agent.challenge',()=>channel.challenge(principal?`agent:${principal.id}:${principal.generation}:${serviceId}`:'local-agent',ed.privateKey,pq.privateKey));if(!enabled||(principal&&!principalLive(principal)))throw fail(403,'Agent access revoked');challenges.set(item.bundle.id,{...item,principalId:principal?.id});reply(200,item.bundle);return;}
|
||||
let body; try { body = JSON.parse(wireBody.toString('utf8')); } catch { throw fail(400, 'Invalid JSON'); }
|
||||
try{await traceOperation('agent.decrypt',()=>{if(!body||Object.keys(body).sort().join(',')!=='handshake,payload')throw Error();const item=challenges.get(body.handshake?.id);if(!item||item.principalId!==principal?.id)throw Error();challenges.delete(item.bundle.id);bundle=item.bundle;protectedContext=channel.receive(body.handshake,bundle,item.privateKey);const value=channel.finish(body.payload,bundle,protectedContext);if(value?.kind!=='agent-request'||Object.keys(value).sort().join(',')!=='action,args,kind')throw Error();body={action:value.action,args:value.args};});}catch{protectedContext?.responseKey.fill(0);protectedContext=undefined;throw fail(400,'Protected agent channel refused');}
|
||||
const request = validate(body); action = request.action;validatedRequest=request;
|
||||
if (typeof actions[action] !== 'function') throw fail(403, 'Action unavailable');
|
||||
running.add(controller);
|
||||
running.add(controller);if(principal)owners.set(controller,principal.id);
|
||||
// Authorizers must be side-effect free: individual grants are checked at each release boundary.
|
||||
const reauthorize=async()=>{if(!principal)return;const grant=await traceOperation('agent.authorize',()=>authorize({...request,principalId:principal.id,generation:principal.generation,signal:controller.signal}));if(!enabled||!principalLive(principal)||controller.signal.aborted||grant?.allowed!==true||!['agent','admin'].includes(grant.role)||!Array.isArray(grant.scopes)||!grant.scopes.includes(request.scope))throw fail(403,'Policy denied action');};
|
||||
const operation = (async () => {
|
||||
const grant = await authorize({ ...request, signal: controller.signal });
|
||||
if (!enabled || controller.signal.aborted || grant?.allowed !== true || !['agent', 'admin'].includes(grant.role) || !Array.isArray(grant.scopes) || !grant.scopes.includes(request.scope)) throw fail(403, 'Policy denied action');
|
||||
await audit({ action, scope: request.scope, sessionId: request.sessionId, outcome: 'authorized' });
|
||||
if (!enabled || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
return project(action, await actions[action](request.args, { signal: controller.signal }));
|
||||
const grant = await traceOperation('agent.authorize',()=>authorize({ ...request,...(principal?{principalId:principal.id,generation:principal.generation}:{}), signal: controller.signal }));
|
||||
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted || grant?.allowed !== true || !['agent', 'admin'].includes(grant.role) || !Array.isArray(grant.scopes) || !grant.scopes.includes(request.scope)) throw fail(403, 'Policy denied action');
|
||||
await traceOperation('agent.audit',()=>audit({ ...(principal?{principalId:principal.id,generation:principal.generation}:{}),action, scope: request.scope, sessionId: request.sessionId, outcome: 'authorized' }));
|
||||
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
await reauthorize();
|
||||
const value=await traceOperation('agent.dispatch',()=>actions[action](request.args, { signal: controller.signal,...(verifiedPrincipal?{principalId:verifiedPrincipal.id,generation:verifiedPrincipal.generation}:{}) }));return traceOperation('agent.project',()=>project(action,value));
|
||||
})();
|
||||
const result = await Promise.race([operation, new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(fail(504, 'Action timed out')); }, timeoutMs); })]);
|
||||
if (!enabled || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
await reauthorize();
|
||||
outcome = 'completed';
|
||||
await audit({ action, outcome });
|
||||
reply(200, { result });
|
||||
await traceOperation('agent.audit',()=>audit({ ...(principal?{principalId:principal.id,generation:principal.generation}:{}),action, outcome,scope:request.scope,sessionId:request.sessionId }));
|
||||
if (!enabled || (principal&&!principalLive(principal)) || controller.signal.aborted) throw fail(403, 'Agent access revoked');
|
||||
await reauthorize();
|
||||
const encryptedReply=await traceOperation('agent.encrypt',()=>channel.response({kind:'agent-response',status:200,result},bundle,protectedContext));await reauthorize();if(!enabled||(principal&&!principalLive(principal))||controller.signal.aborted)throw fail(403,'Agent access revoked');reply(200,encryptedReply);
|
||||
} catch (error) {
|
||||
outcome=(error.status||500)>=500?'failed':'denied';
|
||||
// Diagnostics expose only known static reasons, never arbitrary adapter errors.
|
||||
if (typeof onError === 'function') {
|
||||
const reasons=['Document changed before action','Managed session unavailable','Unknown managed session','Session destination blocked','Agent scope denied','Agent destination denied','Managed session changed during action','Sensitive content cannot be returned','Inspection unavailable','Company policy blocks content access','Target is unavailable or sensitive','Target cannot receive text','Invalid text response','Policy denied action','Action timed out'];
|
||||
const reasons=['Document changed before action','Managed session unavailable','Unknown managed session','Session destination blocked','Agent scope denied','Agent destination denied','Managed session changed during action','Sensitive content cannot be returned','Inspection unavailable','Company policy blocks content access','Target is unavailable or sensitive','Target cannot receive text','Invalid text response','Policy denied action','Action timed out',
|
||||
'Management update in progress. Try again.','Managed policy changed during bookmark update','Bookmark update awaits management synchronization','Bookmark update requires a fresh signed policy','Bookmarks changed. Refresh before editing.','Bookmark changes are controlled by your administrator','Company bookmarks cannot be changed','Bookmark identity is unavailable','Invalid bookmark details','Bookmark destination is not approved','Managed browser access is paused','Managed application changed','Management request refused','Management response exceeds its limit','Protected management identity unavailable','Policy changed without a new version','Company policy expired','Required private content inspector is unavailable','Security event queue is full'];
|
||||
const reason=reasons.find(value=>String(error.message).includes(value)) || 'unclassified-action-failure';
|
||||
try { await onError({action:action || 'agent-request',status:error.status || 500,reason}); } catch { /* Diagnostics cannot change enforcement. */ }
|
||||
}
|
||||
try { await audit({ action: action || 'agent-request', outcome, status: error.status || 500 }); } catch { /* Deny even if audit unavailable. */ }
|
||||
reply(error.status || 500, { error: error.status ? error.message : 'Agent action failed' });
|
||||
} finally { clearTimeout(timer); running.delete(controller); }
|
||||
});
|
||||
server.requestTimeout = 15000; server.headersTimeout = 10000; server.maxHeadersCount = 32;
|
||||
try { await traceOperation('agent.audit',()=>audit({ ...(verifiedPrincipal?{principalId:verifiedPrincipal.id,generation:verifiedPrincipal.generation}:{}),action: action || 'agent-request', outcome, status: error.status || 500,...(validatedRequest?{scope:validatedRequest.scope,sessionId:validatedRequest.sessionId}:{}) })); } catch { /* Deny even if audit unavailable. */ }
|
||||
const busy=action==='bookmarks:mutate'&&error.status===409&&error.code==='browser-management-busy'&&error.message==='Management update in progress. Try again.';
|
||||
const data={kind:'agent-response',status:error.status||500,error:error.status?error.message:'Agent action failed',...(busy?{code:'browser-management-busy'}:{})};reply(error.status||500,protectedContext?channel.response(data,bundle,protectedContext):{error:data.error});
|
||||
} finally { clearTimeout(timer); running.delete(controller);owners.delete(controller); protectedContext?.responseKey.fill(0); }
|
||||
}));
|
||||
server.maxConnections=32; server.requestTimeout = 15000; server.headersTimeout = 10000; server.maxHeadersCount = 32;
|
||||
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
||||
const address = `http://127.0.0.1:${server.address().port}`;
|
||||
const revoke = () => { enabled = false; for (const c of running) c.abort(); };
|
||||
const close = async () => { revoke(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); };
|
||||
const revoke = () => { enabled = false; challenges.clear(); proofs.clear(); for (const c of running) c.abort(); };
|
||||
const expiryTimer=setInterval(()=>{for(const [id,p]of principalRecords)if(!principalLive(p)){invalidate(id);principalRecords.delete(id);}},100);expiryTimer.unref();
|
||||
const close = async () => { clearInterval(expiryTimer);revoke(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); };
|
||||
try {
|
||||
const credential = { address, token };
|
||||
if (persistCredential) await persistCredential(credential);
|
||||
if (credentialFile) {
|
||||
if (!path.isAbsolute(credentialFile)) throw new TypeError('Credential path must be absolute');
|
||||
await fs.writeFile(credentialFile, JSON.stringify(credential), { mode: 0o600, flag: 'wx' });
|
||||
}
|
||||
const credential = { address, token, edPublic, pqPublic, protocol:channel.VERSION };
|
||||
if (persistCredential&&principalMode==='legacy') await persistCredential(credential);
|
||||
} catch (error) { await close(); throw error; }
|
||||
return { address, token, close, revoke };
|
||||
const issuePrincipal=(id,generation,expiresAt)=>{
|
||||
const g=approved.get(id);if(principalMode!=='individual'||!g||g.enabled===false||g.generation!==generation||g.expiresAt!==expiresAt||Date.parse(expiresAt)<=Date.now()||!enabled)throw fail(403,'Principal grant unavailable');
|
||||
let p=principalRecords.get(id);if(!principalLive(p)){invalidate(id);p={id,generation,expiresAt,token:crypto.randomBytes(32).toString('base64url')};principalRecords.set(id,p);}
|
||||
return {address,token:p.token,edPublic,pqPublic,protocol:channel.VERSION,principalId:id,generation,expiresAt,principalBinding:`agent:${id}:${generation}:${serviceId}`};
|
||||
};
|
||||
return { address,...(principalMode==='legacy'?{token}:{}),edPublic,pqPublic,protocol:channel.VERSION,close,revoke,issuePrincipal,reconcilePrincipals };
|
||||
}
|
||||
module.exports = { startAgentControl, SCOPES };
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
const fs=require('node:fs');
|
||||
const path=require('node:path');
|
||||
const crypto=require('node:crypto');
|
||||
const {SCOPES}=require('./agent-control.cjs');
|
||||
const validId=id=>typeof id==='string'&&/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(id);
|
||||
const live=(value,now)=>Number.isFinite(Date.parse(value))&&Date.parse(value)>now;
|
||||
function activeGrants(policy,now=Date.now()){
|
||||
if(policy?.agents?.identityMode!=='individual'||!live(policy.expiresAt,now))return [];
|
||||
return (policy.agents.grants||[]).slice(0,32).filter(g=>validId(g.id)&&g.enabled===true&&live(g.expiresAt,now)&&Number.isSafeInteger(g.generation)&&g.generation>=1).map(g=>structuredClone(g));
|
||||
}
|
||||
function authorizeGrant({policy,ready,principalId,generation,action,url,hostMatches,now=Date.now()}){
|
||||
const denied={allowed:false,role:'agent',scopes:[]};
|
||||
const agents=policy?.agents,scope=SCOPES[action];
|
||||
if(!ready||agents?.enabled!==true||!live(policy.expiresAt,now)||!scope||!agents.scopes?.includes(action))return denied;
|
||||
let grant;
|
||||
if(agents.identityMode==='individual'){
|
||||
grant=activeGrants(policy,now).find(g=>g.id===principalId&&g.generation===generation);
|
||||
if(!grant||!grant.scopes.includes(action))return denied;
|
||||
}else if(agents.identityMode!=='legacy'||principalId!==undefined||generation!==undefined)return denied;
|
||||
if(url!==undefined){
|
||||
let address;try{address=new URL(url);}catch{return denied;}
|
||||
if(!['http:','https:'].includes(address.protocol)||address.username||address.password)return denied;
|
||||
for(const patterns of [agents.allowedHosts,grant?.allowedHosts]){
|
||||
if(patterns?.length&&(!hostMatches||!patterns.some(pattern=>hostMatches(address.hostname,pattern))))return denied;
|
||||
}
|
||||
}
|
||||
return {allowed:true,role:'agent',scopes:[scope]};
|
||||
}
|
||||
function reconcileGenerationFloor(grants,floor={}){
|
||||
const next=Object.create(null);
|
||||
for(const [id,value]of Object.entries(floor))if(validId(id)&&Number.isSafeInteger(value)&&value>=1)next[id]=value;
|
||||
const result=grants.map(g=>{
|
||||
if(!validId(g.id)||!Number.isSafeInteger(g.generation)||g.generation<1)throw Error('Invalid agent generation');
|
||||
const prior=next[g.id]||0;next[g.id]=Math.max(prior,g.generation);
|
||||
return {...structuredClone(g),enabled:g.enabled===true&&g.generation>=prior};
|
||||
});
|
||||
return {grants:result,floor:{...next}};
|
||||
}
|
||||
function descriptorPath(directory,id){
|
||||
if(!validId(id))throw Error('Invalid agent identifier');
|
||||
return path.join(directory,'agent-'+crypto.createHash('sha256').update(id).digest('hex')+'.json');
|
||||
}
|
||||
function guard(filePath){
|
||||
const absolute=path.resolve(filePath),parent=path.dirname(absolute);
|
||||
let current=parent;
|
||||
while(true){const stat=fs.lstatSync(current);if(!stat.isDirectory()||stat.isSymbolicLink()||fs.realpathSync(current).toLowerCase()!==path.resolve(current).toLowerCase())throw Error('Unsafe credential directory');const next=path.dirname(current);if(next===current)break;current=next;}
|
||||
try{const stat=fs.lstatSync(absolute);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1)throw Error('Unsafe credential file');}catch(error){if(error.code!=='ENOENT')throw error;}
|
||||
return absolute;
|
||||
}
|
||||
async function persistConnection({filePath,connection,protect,isCurrent}){
|
||||
if(typeof protect!=='function'||typeof isCurrent!=='function'||!isCurrent())throw Error('Agent credential context changed');
|
||||
const target=guard(filePath);
|
||||
const encrypted=await protect(JSON.stringify(connection));
|
||||
if(!isCurrent())throw Error('Agent credential context changed');
|
||||
const bytes=Buffer.from(JSON.stringify({format:'windows-dpapi-v1',protectedConnection:encrypted}));
|
||||
const temporary=target+'.'+crypto.randomUUID()+'.tmp';let fd;
|
||||
try{
|
||||
guard(target);fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,bytes);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;
|
||||
if(!isCurrent())throw Error('Agent credential context changed');guard(target);fs.renameSync(temporary,target);
|
||||
return target;
|
||||
}finally{bytes.fill(0);if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch(error){if(error.code!=='ENOENT')throw error;}}
|
||||
}
|
||||
module.exports={activeGrants,authorizeGrant,reconcileGenerationFloor,descriptorPath,persistConnection};
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
const channel=require('../../packages/core/policy-channel.cjs');
|
||||
const crypto=require('node:crypto');
|
||||
function authorization(token,route,body='{}',principal){const time=Date.now(),nonce=crypto.randomBytes(32).toString('base64url');const mac=crypto.createHmac('sha384',token).update(principal?JSON.stringify(['safetynet-agent-auth-v1',route,principal.principalId,principal.generation,time,nonce,crypto.createHash('sha384').update(body).digest('base64url')]):`${route}:${time}:${nonce}:${crypto.createHash('sha384').update(body).digest('base64url')}`).digest('base64url');return principal?`SafetyNet-Agent-PQ ${principal.principalId}.${principal.generation}.${time}.${nonce}.${mac}`:`SafetyNet-PQ ${time}.${nonce}.${mac}`;}
|
||||
async function boundedJson(response,max){const reader=response.body.getReader();const chunks=[];let size=0;try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>max)throw Error('Protected agent response too large');chunks.push(Buffer.from(value));}return JSON.parse(Buffer.concat(chunks).toString('utf8'));}finally{await reader.cancel();}}
|
||||
async function requestAttempt(credential, action, options={}) {
|
||||
const url=new URL(credential.address);
|
||||
if(url.protocol!=='http:'||url.hostname!=='127.0.0.1'||url.username||url.password||url.pathname!=='/'||url.search||url.hash||credential.protocol!==channel.VERSION)throw Error('Protected agent credential required');
|
||||
const individual=credential.principalId!==undefined;if(individual&&(!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(credential.principalId)||!Number.isSafeInteger(credential.generation)||credential.generation<1||!credential.principalBinding?.startsWith(`agent:${credential.principalId}:${credential.generation}:`)||!/^.{1,120}:[0-9a-f-]{36}$/.test(credential.principalBinding)||!Number.isFinite(Date.parse(credential.expiresAt))||Date.parse(credential.expiresAt)<=Date.now()))throw Error('Invalid principal credential');
|
||||
const headers=(route,body='{}')=>({'Content-Type':'application/json',Authorization:authorization(credential.token,route,body,individual?credential:undefined),...options.headers});
|
||||
const challenge=await fetch(`${url.origin}/v1/challenge`,{method:'POST',headers:headers('/v1/challenge'),body:'{}',redirect:'error',signal:AbortSignal.timeout(10000)});
|
||||
if(!challenge.ok){await boundedJson(challenge,32768);return {ok:false,status:challenge.status,json:async()=>({error:'Protected agent challenge refused'})};}
|
||||
const bundle=await boundedJson(challenge,32768);
|
||||
channel.verifyChallenge(bundle,individual?credential.principalBinding:'local-agent',credential.edPublic,credential.pqPublic);
|
||||
const context=channel.prepare(bundle);
|
||||
try{
|
||||
const payload=channel.response({kind:'agent-request',action:action.action,args:action.args??{}},bundle,context);
|
||||
const body=JSON.stringify({handshake:context.request,payload});if(Buffer.byteLength(body)>131072)throw Error('Protected agent request too large');
|
||||
const response=await fetch(`${url.origin}/v1/actions`,{method:'POST',headers:headers('/v1/actions',body),body,redirect:'error',signal:AbortSignal.timeout(65000)});
|
||||
const value=await boundedJson(response,4*1024*1024+65536);
|
||||
if(!value.encrypted&&response.ok)throw Error('Protected agent response required');
|
||||
const data=value.encrypted?channel.finish(value,bundle,context):value;
|
||||
if(value.encrypted&&(data.kind!=='agent-response'||!Number.isInteger(data.status)||data.status<200||data.status>599))throw Error('Protected agent response refused');
|
||||
const status=value.encrypted?data.status:response.status,ok=value.encrypted&&status>=200&&status<300;
|
||||
const busy=value.encrypted&&status===409&&action.action==='bookmarks:mutate'&&data.code==='browser-management-busy'&&data.error==='Management update in progress. Try again.';
|
||||
const clean=data.error?{error:data.error,...(busy?{code:data.code}:{})}:{result:data.result};
|
||||
return {ok,status,json:async()=>clean,...(busy?{retryManagementBusy:true}:{})};
|
||||
}finally{context.responseKey.fill(0);}
|
||||
}
|
||||
async function requestAgent(credential, action, options={}) {
|
||||
// Snapshot validated JSON arguments: retries cannot update CAS or signed-policy inputs.
|
||||
const fixed=JSON.parse(JSON.stringify(action));
|
||||
for(let retry=0;;retry++){
|
||||
const response=await requestAttempt(credential,fixed,options);
|
||||
if(!response.retryManagementBusy||retry>=3){delete response.retryManagementBusy;return response;}
|
||||
// Each attempt creates a fresh authenticated proof and consumes a new signed challenge.
|
||||
await new Promise(resolve=>setTimeout(resolve,200*(retry+1)));
|
||||
}
|
||||
}
|
||||
module.exports={requestAgent,authorization};
|
||||
@@ -1,4 +1,5 @@
|
||||
'use strict';
|
||||
const {validateStages}=require('../../packages/core/inspection-stages.cjs');
|
||||
const {AsyncLocalStorage}=require('node:async_hooks');
|
||||
const {performance}=require('node:perf_hooks');
|
||||
const {randomInt}=require('node:crypto');
|
||||
@@ -9,11 +10,15 @@ 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.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(['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','click','type','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']);
|
||||
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 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']);
|
||||
for(const name of ['browser.upload.lease','browser.upload.part','browser.upload.release','browser.upload.fault'])names.add(name);
|
||||
// Diagnostic reasons are fixed code identifiers, never exception messages,
|
||||
// policy rule text, request content or arbitrary caller-supplied labels.
|
||||
const reasons=new Set(['request-source-context-changed','header-source-context-changed','headers-inspection-timeout','request-inspection-timeout','upload-lease-lost','upload-lease-release-failed','invalid-source-owner','policy-changed']);
|
||||
const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
||||
function attributes(input){const out={};if(!input||typeof input!=='object')return out;for(const [key,descriptor]of Object.entries(Object.getOwnPropertyDescriptors(input))){if(!('value'in descriptor))continue;const value=descriptor.value;
|
||||
if(['sessionId','deviceId'].includes(key)&&typeof value==='string'&&uuid.test(value))out[key==='sessionId'?'safetynet.session.id':'safetynet.device.id']=value;
|
||||
@@ -24,11 +29,18 @@ function attributes(input){const out={};if(!input||typeof input!=='object')retur
|
||||
else if(key==='decision'&&decisions.has(value))out['dlp.decision']=value;
|
||||
else if(key==='status'&&statuses.has(value))out['safetynet.status']=value;
|
||||
else if(key==='requestType'&&requestTypes.has(value))out['safetynet.request.type']=value;
|
||||
else if(key==='reason'&&reasons.has(value))out['safetynet.reason']=value;
|
||||
}return out;}
|
||||
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;
|
||||
// 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;
|
||||
const batch=validateStages(envelope);if(!batch)return;const base=batch.baseUnixMs,validated=batch.records.map(item=>({...item,start:item.startOffsetMs,duration:item.durationMs}));
|
||||
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 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;
|
||||
@@ -51,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,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,activeSpans})};
|
||||
}
|
||||
module.exports={createDiagnostics,attributes,endpointAllowed};
|
||||
@@ -1,12 +1,14 @@
|
||||
'use strict';
|
||||
const {safeFilename:filename,inspectFileTransfer}=require('../../packages/core/file-transfer.cjs');
|
||||
const {createHash}=require('node:crypto');
|
||||
const {buildDownloadEvidence}=require('./evidence-capture.cjs');
|
||||
function createDownloadGate({getContext,isCurrent,fetchResponse,scan,choosePath,writeFile,audit,status}){
|
||||
let busy=false;
|
||||
return async function download({url,name,method,webContentsId}){
|
||||
const context=getContext(webContentsId);let bytes,facts;
|
||||
const auditFacts=()=>facts?{bytes:facts.size,detectedType:facts.detectedType,sha256:facts.sha256}:{};
|
||||
const deny=async reason=>{await audit('download.block',reason,new URL(url).hostname,auditFacts());status?.(`Download blocked: ${reason}`);return {released:false,reason};};
|
||||
const context=getContext(webContentsId);let bytes,facts,mime;
|
||||
const auditFacts=()=>facts?{bytes:facts.size,detectedType:facts.detectedType,sha256:facts.sha256}:undefined;
|
||||
const evidence=(decision,ruleId)=>context?.ready&&isCurrent(context)?buildDownloadEvidence({url,name,mime,facts,decision,ruleId},context.policy):null;
|
||||
const deny=async reason=>{await audit('download.block',reason,new URL(url).hostname,auditFacts(),evidence('block',reason));status?.(`Download blocked: ${reason}`);return {released:false,reason};};
|
||||
if(busy)return deny('download-busy');
|
||||
if(!context||context.webContentsId!==webContentsId)return deny('unmanaged-download');
|
||||
if(!context.ready||!context.policy?.browser.allowDownloads)return deny('downloads-disabled');
|
||||
@@ -27,7 +29,7 @@ function createDownloadGate({getContext,isCurrent,fetchResponse,scan,choosePath,
|
||||
finally{reader.releaseLock();}
|
||||
bytes=Buffer.concat(chunks);const digest=createHash('sha256').update(bytes).digest('hex');
|
||||
if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed');
|
||||
facts=inspectFileTransfer(bytes,context.policy,'download',{filename:name,mime:response.headers.get('content-type')});if(facts.decision!=='allow')return deny(facts.ruleId);
|
||||
mime=response.headers.get('content-type');facts=inspectFileTransfer(bytes,context.policy,'download',{filename:name,mime});if(facts.decision!=='allow')return deny(facts.ruleId);
|
||||
const result=await scan(bytes,context.policy);
|
||||
if(result.decision!=='allow'||result.coverage?.complete!==true||result.sha256!==digest)return deny(result.ruleId||'yara-scan-incomplete');
|
||||
if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed');
|
||||
@@ -37,7 +39,7 @@ function createDownloadGate({getContext,isCurrent,fetchResponse,scan,choosePath,
|
||||
if(!isCurrent(context)||controller.signal.aborted)return deny('policy-changed');
|
||||
// The only file-writing operation occurs after verified scanning of these exact bytes.
|
||||
await writeFile(selected,bytes,()=>isCurrent(context)&&!controller.signal.aborted);
|
||||
await audit('download.released',scanLabel,new URL(url).hostname,auditFacts());status?.('Download scanned and saved.');return {released:true,sha256:digest,facts};
|
||||
await audit('download.released',scanLabel,new URL(url).hostname,auditFacts(),evidence('allow',scanLabel));status?.('Download scanned and saved.');return {released:true,sha256:digest,facts};
|
||||
}catch{return deny('download-inspection-failed');}
|
||||
finally{clearTimeout(timeout);bytes?.fill(0);busy=false;}
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ async function scanDownloadEnterprise(bytes,policy,localInspector,scanner){
|
||||
if(noAv&&!policy.fileTransfers?.download?.allowWithoutMalwareScanner)return blocked('malware-scanner-required');
|
||||
// Antivirus clean does not classify company secrets. Supported documents always
|
||||
// require the contained DLP parser; explicit no-antivirus mode still requires DLP.
|
||||
if(noAv||policy.dlp?.semantic?.enabled||['pdf','zip','png','jpeg','gif','webp','text'].includes(detectType(bytes))){
|
||||
if(noAv||(policy.fileTransfers?.download?.requireDocumentDlp!==false&&(policy.dlp?.semantic?.enabled||['pdf','zip','ole','png','jpeg','gif','webp','text'].includes(detectType(bytes))))){
|
||||
if(!localInspector?.inspect)return blocked('download-dlp-unavailable');
|
||||
let dlp;try{dlp=await localInspector.inspect(bytes,policy);}catch{return blocked('download-dlp-unavailable');}
|
||||
if(dlp.decision!=='allow')return blocked(typeof dlp.ruleId==='string'?dlp.ruleId:'download-dlp-blocked');
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
const {safeFilename}=require('../../packages/core/file-transfer.cjs');
|
||||
const credentialPatterns=[/-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY)-----/i,/\b(?:bearer|basic)\s+[A-Za-z0-9+/_=-]{8,}/i,/\b(?:password|passwd|pwd|api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|authorization)\b["']?\s*[:=]\s*["']?[^\s"',;]{4,}/i,/\bsk-[A-Za-z0-9_-]{16,}\b/,/\bgh[pousr]_[A-Za-z0-9]{20,}\b/,/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,/\bAKIA[A-Z0-9]{16}\b/,/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/];
|
||||
function validateDownloadEvidence(value){
|
||||
if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).some(k=>!['sourceUrl','name','sha256','bytes','mime','decision','ruleId'].includes(k)))return false;
|
||||
if(typeof value.sourceUrl!=='string'||value.sourceUrl.length>4096||typeof value.name!=='string'||!value.name.length||value.name.length>255||typeof value.mime!=='string'||value.mime.length>100||!/^[a-f0-9]{64}$/.test(value.sha256)||!Number.isSafeInteger(value.bytes)||value.bytes<0||!['allow','block'].includes(value.decision))return false;
|
||||
if(value.ruleId!==undefined&&(typeof value.ruleId!=='string'||value.ruleId.length>100))return false;
|
||||
try{const u=new URL(value.sourceUrl);if(!['http:','https:'].includes(u.protocol)||u.username||u.password||u.search||u.hash)return false;const fields=[value.name,value.sourceUrl,decodeURIComponent(u.pathname)];if(fields.some(v=>credentialPatterns.some(p=>p.test(v))))return false;}catch{return false;}
|
||||
return true;
|
||||
}
|
||||
// Only bounded file metadata is captured. The document itself never enters the
|
||||
// forensic queue; main persists this beside its source audit event atomically.
|
||||
function buildDownloadEvidence({url,name,mime,facts,decision,ruleId},policy){
|
||||
if(policy?.evidence?.enabled!==true||policy.evidence.captureDownloads!==true)return null;
|
||||
if(!facts||!Number.isSafeInteger(facts.size)||facts.size<0||!/^[a-f0-9]{64}$/.test(facts.sha256)||!['allow','block'].includes(decision))return null;
|
||||
let source;try{source=new URL(url);if(!['http:','https:'].includes(source.protocol))return null;source.username='';source.password='';source.search='';source.hash='';}catch{return null;}
|
||||
if(source.href.length>4096)return null;
|
||||
const result={sourceUrl:source.href,name:safeFilename(name),sha256:facts.sha256,bytes:facts.size,mime:String(mime||'').split(';',1)[0].trim().toLowerCase().replace(/[\x00-\x1f\x7f]/g,'').slice(0,100),decision};
|
||||
if(typeof ruleId==='string'&&ruleId.length<=100&&!/[\x00-\x1f\x7f]/.test(ruleId))result.ruleId=ruleId;
|
||||
return validateDownloadEvidence(result)?result:null;
|
||||
}
|
||||
module.exports={buildDownloadEvidence,validateDownloadEvidence};
|
||||
@@ -0,0 +1,16 @@
|
||||
'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);}
|
||||
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};}
|
||||
catch(error){if(error.status===403)return {eventDelivered:true,evidenceDiscarded:true};throw error;}
|
||||
}
|
||||
module.exports={validatedEvidence,collectionAllowed,stripPendingEvidence,deliverAuditRecord};
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
const {createHash}=require('node:crypto');
|
||||
// Owns Windows read-only leases until Chromium reports a terminal request event.
|
||||
// A denied callback is not a terminal event: releasing there would reopen a race.
|
||||
function createFileUploadGate({createLease,inspectPart,isCurrent,abort,maxBytes}){
|
||||
const pending=new Map();let disposed=false;
|
||||
const deny=ruleId=>({decision:'block',ruleId});
|
||||
function alive(state){return !disposed&&!state.cancelled&&isCurrent(state.context)&&state.leases.every(lease=>lease.isAlive());}
|
||||
function cancel(state){state.cancelled=true;try{abort(state.id);}catch{} }
|
||||
async function release(state){for(const lease of state.leases)await lease.release();state.leases.length=0;}
|
||||
function snapshot(details,context){
|
||||
const parts=details.uploadData,limit=maxBytes(context),copies=[];
|
||||
try{
|
||||
if(!Array.isArray(parts)||!parts.length||parts.length>256||!Number.isSafeInteger(limit)||limit<0)throw Error();
|
||||
const hash=createHash('sha256');hash.update(JSON.stringify([details.url,details.method]));let rawBytes=0;
|
||||
const input=parts.map(part=>{
|
||||
if(part?.type==='rawData'&&!part.file&&!part.blobUUID&&Buffer.isBuffer(part.bytes)){
|
||||
rawBytes+=part.bytes.length;if(rawBytes>limit)throw Error();
|
||||
const bytes=Buffer.from(part.bytes);copies.push(bytes);hash.update(JSON.stringify(['raw',bytes.length]));hash.update(bytes);return {type:'rawData',bytes};
|
||||
}
|
||||
if(part?.type==='file'&&typeof part.file==='string'&&part.file.length&&part.offset===0&&part.length===-1&&!part.bytes&&!part.blobUUID){
|
||||
hash.update(JSON.stringify(['file',part.file,0,-1]));return {type:'file',file:part.file,offset:0,length:-1};
|
||||
}throw Error();
|
||||
});
|
||||
return {uploadData:input,copies,fingerprint:hash.digest('hex')};
|
||||
}catch(error){for(const copy of copies)copy.fill(0);throw error;}
|
||||
}
|
||||
async function run(state,details){
|
||||
const buffers=[...details.copies];
|
||||
try{
|
||||
if(!alive(state))return deny('policy-changed');
|
||||
const parts=details.uploadData;
|
||||
if(!Array.isArray(parts)||!parts.length||parts.length>256)return deny('uninspectable-upload');
|
||||
if(parts.some(part=>!part||!(part.type==='rawData'&&!part.file&&!part.blobUUID&&Buffer.isBuffer(part.bytes)||part.type==='file'&&typeof part.file==='string'&&part.file.length&&part.offset===0&&part.length===-1&&!part.bytes&&!part.blobUUID)))return deny('uninspectable-upload');
|
||||
const limit=maxBytes(state.context);
|
||||
if(!Number.isSafeInteger(limit)||limit<0||parts.reduce((sum,part)=>sum+(part.type==='rawData'?part.bytes.length:0),0)>limit)return deny('size-limit');
|
||||
const paths=parts.filter(part=>part?.type==='file').map(part=>part.file);
|
||||
let lease;
|
||||
if(paths.length){lease=await createLease({paths,maxBytes:limit,onLost:()=>cancel(state)});state.leases.push(lease);if(!alive(state))return deny('upload-lease-lost');}
|
||||
let fileIndex=0,total=0;
|
||||
for(const part of parts){
|
||||
if(!part||typeof part!=='object')return deny('uninspectable-upload');
|
||||
let bytes,kind;
|
||||
if(part.type==='rawData'&&!part.file&&!part.blobUUID&&Buffer.isBuffer(part.bytes)){
|
||||
bytes=part.bytes;kind='raw';
|
||||
}else if(part.type==='file'&&typeof part.file==='string'&&part.file.length&&part.offset===0&&part.length===-1&&!part.bytes&&!part.blobUUID){
|
||||
const file=lease.files[fileIndex++];
|
||||
if(file?.path!==part.file)return deny('uninspectable-upload');
|
||||
bytes=file.bytes;kind='file';
|
||||
if(!Buffer.isBuffer(bytes))return deny('uninspectable-upload');
|
||||
}else return deny('uninspectable-upload');
|
||||
total+=bytes.length;
|
||||
if(!Number.isSafeInteger(total)||total>maxBytes(state.context))return deny('size-limit');
|
||||
state.parts.push({bytes,kind,filename:kind==='file'?part.file:undefined});
|
||||
}
|
||||
if(!alive(state))return deny('policy-changed');
|
||||
// Inspect actual file bytes independently; multipart wrappers must not mask magic.
|
||||
for(const part of state.parts){
|
||||
const verdict=await inspectPart(part.bytes,{kind:part.kind,context:state.context,filename:part.filename});
|
||||
if(!alive(state))return deny('policy-changed');
|
||||
if(verdict?.decision!=='allow'||verdict.coverage?.complete!==true)return deny(verdict?.ruleId||'inspection-incomplete');
|
||||
}
|
||||
// Ordered raw segments are also joined: catches literals split at native boundaries.
|
||||
const raw=Buffer.concat(state.parts.filter(part=>part.kind==='raw').map(part=>part.bytes));buffers.push(raw);
|
||||
const verdict=await inspectPart(raw,{kind:'raw',context:state.context});
|
||||
if(!alive(state))return deny('policy-changed');
|
||||
if(verdict?.decision!=='allow'||verdict.coverage?.complete!==true)return deny(verdict?.ruleId||'inspection-incomplete');
|
||||
return {decision:'allow',ruleId:null,request:{bytes:total,detectedType:'unknown'}};
|
||||
}catch{return deny('inspection-incomplete');}
|
||||
finally{for(const buffer of buffers)buffer.fill(0);for(const lease of state.leases)for(const file of lease.files||[])file.bytes?.fill(0);state.parts.length=0;}
|
||||
}
|
||||
return {
|
||||
inspect(details,context){
|
||||
if(disposed)return Promise.resolve(deny('session-disposed'));
|
||||
let input;try{input=snapshot(details,context);}catch{return Promise.resolve(deny('uninspectable-upload'));}
|
||||
let state=pending.get(details.id);
|
||||
if(state){for(const copy of input.copies)copy.fill(0);if(state.fingerprint!==input.fingerprint||state.context!==context||!alive(state)){cancel(state);return Promise.resolve(deny('request-changed'));}return state.promise;}
|
||||
if(pending.size>=8){for(const copy of input.copies)copy.fill(0);return Promise.resolve(deny('upload-capacity'));}
|
||||
state={id:details.id,fingerprint:input.fingerprint,context,leases:[],parts:[],cancelled:false,terminal:false};pending.set(details.id,state);
|
||||
state.promise=run(state,input).then(verdict=>{if(verdict.decision!=='allow')cancel(state);return verdict;});return state.promise;
|
||||
},
|
||||
async terminal(id){const state=pending.get(id);if(!state)return;pending.delete(id);state.terminal=true;state.cancelled=true;await state.promise;await release(state);},
|
||||
cancelAll(){for(const state of pending.values())cancel(state);},
|
||||
// Disposal rejects new work but is not proof that native readers have stopped.
|
||||
// Only a real terminal callback releases handles; process exit closes leftovers.
|
||||
dispose(){disposed=true;for(const state of pending.values())cancel(state);},
|
||||
size(){return pending.size;},
|
||||
has(id){return pending.has(id);}
|
||||
};
|
||||
}
|
||||
module.exports={createFileUploadGate};
|
||||
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
// Final native request headers are scanned in privileged code before release.
|
||||
// No values, names or credential fingerprints are returned as diagnostic facts.
|
||||
const MAX_HEADERS=128,MAX_BYTES=65536;
|
||||
function snapshotHeaders(input,policy){
|
||||
if(!input||typeof input!=='object'||Array.isArray(input)||![Object.prototype,null].includes(Object.getPrototypeOf(input)))throw Error('headers-uninspectable');
|
||||
const entries=Object.entries(Object.getOwnPropertyDescriptors(input));
|
||||
if(entries.length>MAX_HEADERS)throw Error('headers-size-limit');
|
||||
const headers=Object.create(null),values=[],lines=[];let bytes=0;
|
||||
const limit=Math.min(MAX_BYTES,policy.dlp.maxBytes);
|
||||
for(const [name,descriptor]of entries){
|
||||
if(!('value'in descriptor)||!/^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,256}$/.test(name)||typeof descriptor.value!=='string')throw Error('headers-uninspectable');
|
||||
const value=descriptor.value;
|
||||
if(value.length>limit)throw Error('headers-size-limit');
|
||||
if(/[\x00-\x08\x0A-\x1F\x7F]|[\uD800-\uDFFF]/u.test(value))throw Error('headers-uninspectable');
|
||||
bytes+=Buffer.byteLength(name)+Buffer.byteLength(value)+4;
|
||||
if(bytes>limit)throw Error('headers-size-limit');
|
||||
headers[name]=value;values.push(value);lines.push(`${name}: ${value}`);
|
||||
}
|
||||
return {headers:Object.freeze(headers),values,lines,bytes,count:entries.length};
|
||||
}
|
||||
async function inspectHeaders(snapshot,{policy,inspect,inspectLocal,inspectEnterprise,isCurrent}){
|
||||
const blocked=ruleId=>({decision:'block',ruleId});
|
||||
if(!isCurrent())return blocked('policy-changed');
|
||||
// Individual values preserve exact registered fingerprints. The joined value
|
||||
// view also catches a literal split across multiple distinct header fields.
|
||||
for(const text of [...snapshot.values,snapshot.values.join(''),snapshot.lines.join('\r\n')]){
|
||||
const bytes=Buffer.from(text);
|
||||
try{const verdict=inspect(bytes,policy);if(verdict.decision!=='allow')return verdict;}
|
||||
finally{bytes.fill(0);}
|
||||
}
|
||||
const bytes=Buffer.from(snapshot.lines.join('\r\n'));
|
||||
try{
|
||||
if(policy.dlp.semantic.enabled||policy.security.yara.enabled){
|
||||
if(!inspectLocal)return blocked('inspector-unavailable');
|
||||
const verdict=await inspectLocal(bytes,policy);
|
||||
if(!isCurrent())return blocked('policy-changed');
|
||||
if(verdict.decision!=='allow')return verdict;
|
||||
if(verdict.coverage?.complete!==true)return blocked('inspection-incomplete');
|
||||
}
|
||||
if(inspectEnterprise){
|
||||
const verdict=await inspectEnterprise(bytes,policy);
|
||||
if(!isCurrent())return blocked('policy-changed');
|
||||
if(verdict.decision!=='allow')return verdict;
|
||||
if(verdict.coverage?.complete!==true)return blocked('inspection-incomplete');
|
||||
}
|
||||
return isCurrent()?{decision:'allow',ruleId:null}:blocked('policy-changed');
|
||||
}finally{bytes.fill(0);}
|
||||
}
|
||||
module.exports={snapshotHeaders,inspectHeaders,MAX_HEADERS,MAX_BYTES};
|
||||
@@ -0,0 +1,27 @@
|
||||
'use strict';
|
||||
// Native history only: pages and agents cannot supply a navigation target.
|
||||
async function performHistory({contents,action,allowed,current,signal,timeoutMs=10000}={}) {
|
||||
if(!contents||!['back','forward','reload'].includes(action)||typeof allowed!=='function'||typeof current!=='function'||!Number.isFinite(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw Error('Invalid history operation');
|
||||
const guard=async(phase='before')=>{if(signal?.aborted)throw Error('History operation cancelled');if(contents.isDestroyed?.())throw Error('History contents unavailable');if(await current(phase)!==true)throw Error('History context changed');if(signal?.aborted)throw Error('History operation cancelled');};
|
||||
const target=()=>action==='reload'?contents.getURL():contents.navigationHistory.getEntryAtIndex(contents.navigationHistory.getActiveIndex()+(action==='back'?-1:1))?.url;
|
||||
await guard();const url=target();
|
||||
if(!url)return {completed:false,url:contents.getURL()};
|
||||
let parsed;try{parsed=new URL(url);}catch{throw Error('Invalid history destination');}
|
||||
if(!['http:','https:'].includes(parsed.protocol)||parsed.username||parsed.password||!await allowed(url))throw Error('History destination denied');
|
||||
await guard();if(target()!==url)throw Error('History destination changed');
|
||||
return new Promise((resolve,reject)=>{
|
||||
let settled=false,dispatched=false,checking=false;
|
||||
const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);for(const [name,fn] of listeners)contents.removeListener(name,fn);};
|
||||
const fail=error=>{if(settled)return;settled=true;cleanup();if(dispatched&&!contents.isDestroyed?.())try{contents.stop();}catch{}reject(error);};
|
||||
const complete=async()=>{if(!dispatched||settled||checking)return;checking=true;try{await guard('after');if(contents.getURL()!==url)throw Error('History destination changed');if(!await allowed(url))throw Error('History destination denied');await guard('after');if(contents.getURL()!==url)throw Error('History destination changed');if(settled)return;settled=true;cleanup();resolve({completed:true,url});}catch(error){fail(error);}finally{checking=false;}};
|
||||
const inPage=(_event,eventUrl,isMainFrame)=>{if(isMainFrame===true){if(eventUrl!==url)fail(Error('History destination changed'));else void complete();}};
|
||||
const loadFailed=(_event,_code,_description,_url,isMainFrame)=>{if(isMainFrame===true)fail(Error('History navigation failed'));};
|
||||
const abort=()=>fail(Error('History operation cancelled'));
|
||||
const listeners=[['did-finish-load',complete],['did-navigate-in-page',inPage],['did-fail-load',loadFailed],['destroyed',()=>fail(Error('History contents unavailable'))]];
|
||||
const timer=setTimeout(()=>fail(Error('History operation timed out')),timeoutMs);
|
||||
for(const [name,fn] of listeners)contents.on(name,fn);signal?.addEventListener('abort',abort,{once:true});
|
||||
if(signal?.aborted){abort();return;}
|
||||
try{dispatched=true;if(action==='reload')contents.reload();else contents.navigationHistory.goToIndex(contents.navigationHistory.getActiveIndex()+(action==='back'?-1:1));}catch(error){fail(error);}
|
||||
});
|
||||
}
|
||||
module.exports={performHistory};
|
||||
+19
-7
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'self' data:; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<title>
|
||||
S Browser
|
||||
</title>
|
||||
@@ -107,6 +107,7 @@
|
||||
<img src="icons/bookmark-plus.svg" alt="">
|
||||
Bookmark this page
|
||||
</button>
|
||||
<button id="bookmark-manager-open" class="menu-item"><img src="icons/bookmark.svg" alt="">Manage bookmarks</button>
|
||||
<button id="reading-add" class="menu-item">
|
||||
<img src="icons/book-open.svg" alt="">
|
||||
Save to Reading List
|
||||
@@ -225,20 +226,25 @@
|
||||
<!-- Trusted start page, shown only while the native website view is hidden. -->
|
||||
<main id="start-page" hidden>
|
||||
<div class="start-content">
|
||||
<p class="start-eyebrow">
|
||||
<img id="start-company-logo" class="company-logo start-company-logo" alt="" hidden>
|
||||
<p id="start-brand-name" class="start-eyebrow">
|
||||
S BROWSER
|
||||
</p>
|
||||
<h1>
|
||||
<h1 id="start-title">
|
||||
Your space to explore.
|
||||
</h1>
|
||||
<p id="start-subtitle" class="start-subtitle" hidden></p>
|
||||
<section id="company-news" class="start-section" hidden aria-labelledby="company-news-title">
|
||||
<div class="section-title"><h2 id="company-news-title">Company news</h2></div>
|
||||
<div id="company-news-items" class="company-news-items"></div>
|
||||
<p id="company-news-empty" class="company-news-empty">Company news is currently unavailable.</p>
|
||||
</section>
|
||||
<section class="start-section">
|
||||
<div class="section-title">
|
||||
<h2>
|
||||
Favorites
|
||||
</h2>
|
||||
<span>
|
||||
Keep what matters close.
|
||||
</span>
|
||||
<button id="favorites-manage" class="text-button">Manage bookmarks</button>
|
||||
</div>
|
||||
<div id="favorites-grid" class="favorites-grid">
|
||||
</div>
|
||||
@@ -473,8 +479,14 @@
|
||||
<div class="actions"><button id="bookmark-cancel" type="button" class="secondary">Cancel</button><button id="bookmark-save" type="submit">Save bookmark</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<script src="renderer.js">
|
||||
<script defer src="renderer.js">
|
||||
</script>
|
||||
<dialog id="bookmark-manager" aria-labelledby="bookmark-manager-title">
|
||||
<header class="manager-header"><div><p class="manager-eyebrow">YOUR LIBRARY</p><h2 id="bookmark-manager-title">Bookmarks</h2></div><button id="bookmark-manager-close" class="icon-button" aria-label="Close bookmark manager">×</button></header>
|
||||
<div class="manager-toolbar"><input id="bookmark-search" type="search" placeholder="Search titles, websites or folders" aria-label="Search bookmarks"><button id="manager-add">Add bookmark</button><button id="manager-ai" disabled>Organize with AI</button></div>
|
||||
<p id="manager-policy" class="bookmark-notice"></p><div id="manager-list" class="manager-list"></div><p id="manager-empty" hidden>No matching bookmarks.</p><p id="manager-error" role="alert"></p>
|
||||
<section id="manager-proposal" hidden aria-label="Organization preview"><h3>Review suggested changes</h3><p>Only your selected bookmarks change. Company bookmarks are managed centrally. Your approved model receives only personal titles and website domains.</p><div id="manager-diff"></div><button id="manager-apply">Apply changes</button><button id="manager-discard">Discard</button></section>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ function privateInspectorPaths(root,packaged=false,environment=process.env){
|
||||
return {python,script};
|
||||
}catch{return null;}
|
||||
}
|
||||
function startInspector(root,packaged=false){
|
||||
function startInspector(root,packaged=false,options={}){
|
||||
const files=privateInspectorPaths(root,packaged);if(!files)return null;
|
||||
const {python,script}=files;
|
||||
const credential=crypto.randomBytes(32).toString('base64url');
|
||||
@@ -44,7 +44,8 @@ function startInspector(root,packaged=false){
|
||||
const response=await require('./loopback-http.cjs').postLoopback({port,route,token:credential,body:{data:Buffer.from(bytes).toString('base64'),policy},signal:controller.signal});
|
||||
if(response.status!==200)throw Error('Local content inspection failed');const result=response.body;
|
||||
if(!['allow','block'].includes(result.decision)||typeof result.coverage?.complete!=='boolean')throw Error('Inspector response rejected');
|
||||
if(result.decision==='allow'&&!result.coverage.complete)throw Error('Incomplete content cannot be released');return result;
|
||||
if(result.decision==='allow'&&!result.coverage.complete)throw Error('Incomplete content cannot be released');
|
||||
const stages=result._diagnosticStages;delete result._diagnosticStages;if(policy?.diagnostics?.enabled===true&&policy.diagnostics.mode==='debug'&&typeof options.onStages==='function'){try{options.onStages(stages);}catch{}}return result;
|
||||
}catch(error){if(error.name==='AbortError'){child.kill();throw Error('Content inspection exceeded its time budget');}throw error;}
|
||||
finally{clearTimeout(timer);}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
// Fixed native operations only. A model never supplies URLs, scripts or handles.
|
||||
function createLibraryOrganization({capture,assertCurrent,propose,commit,applyTabs,inspect,audit,changed=()=>{},autoAllowed=()=>false}={}){
|
||||
if([capture,assertCurrent,propose,commit,applyTabs,inspect,audit].some(fn=>typeof fn!=='function'))throw TypeError('Invalid library callbacks');
|
||||
let busy=false;const proposals=new Map();
|
||||
const checkInput=input=>{if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['proposalId','confirm','autoApply'].includes(k))||typeof input.proposalId!=='string'||input.proposalId.length>128)throw Error('Invalid organization request');};
|
||||
async function plan({signal}={}){
|
||||
if(busy)throw Error('Library organization is already running');busy=true;changed();
|
||||
try{
|
||||
const current=snapshot=>{if(signal?.aborted)throw Error('Organization action cancelled');assertCurrent(snapshot);};
|
||||
const snapshot=await capture();current(snapshot);await inspect(snapshot);current(snapshot);
|
||||
const result=await propose(snapshot);current(snapshot);
|
||||
if(typeof result?.proposalId!=='string'||result.proposalId.length>128)throw Error('Invalid library proposal');
|
||||
if(snapshot.snapshotHash&&(result.binding?.snapshotHash!==snapshot.snapshotHash||result.binding?.policyVersion!==snapshot.policyVersion||result.binding?.bookmarkRevision!==snapshot.bookmarkRevision))throw Error('Invalid organization proposal binding');
|
||||
const core=await import('../../packages/core/library.mjs');
|
||||
const validated=core.validateLibraryPlan(result.plan,{bookmarks:snapshot.bookmarks,tabs:snapshot.tabs});
|
||||
// Keep authoritative plan and binding in native memory, never renderer input.
|
||||
proposals.clear();proposals.set(result.proposalId,{snapshot,plan:validated,expires:Date.now()+300000});
|
||||
await audit('library.ai.proposed',`bookmarks-${validated.bookmarks.length}:tabs-${validated.tabs.length}`);
|
||||
return {...result,plan:structuredClone(validated)};
|
||||
}finally{busy=false;changed();}
|
||||
}
|
||||
async function apply(input,{signal}={}){
|
||||
checkInput(input);if(busy)throw Error('Library organization is already running');
|
||||
const stored=proposals.get(input.proposalId);
|
||||
if(!stored||stored.expires<=Date.now())throw Error('Organization proposal expired. Generate a fresh proposal.');
|
||||
if(input.confirm!==true&&!(input.autoApply===true&&autoAllowed()))throw Error('Review and confirm the organization proposal');
|
||||
if(signal?.aborted)throw Error('Organization action cancelled');assertCurrent(stored.snapshot);busy=true;changed();
|
||||
let committed=false;
|
||||
try{
|
||||
const result=await commit(input.proposalId,stored.snapshot,input.autoApply===true);committed=true;
|
||||
// Bookmarks are committed centrally. Navigation or policy changes can occur
|
||||
// meanwhile: never silently apply stale tab assignments after such a change.
|
||||
if(signal?.aborted)throw Error('Organization action cancelled');assertCurrent(stored.snapshot,{afterCommit:true,result});
|
||||
applyTabs(stored.plan.tabs,result);proposals.delete(input.proposalId);
|
||||
await audit('library.ai.applied',`bookmarks-${stored.plan.bookmarks.length}:tabs-${stored.plan.tabs.length}`);
|
||||
return {completed:true,bookmarkRevision:result.revision,tabCount:stored.plan.tabs.length};
|
||||
}catch(error){
|
||||
if(committed){proposals.delete(input.proposalId);await audit('library.ai.reconcile','central-bookmarks-committed');throw Error('Bookmarks were organized centrally; tabs were unchanged because the browser context changed. Refresh the library.');}
|
||||
throw error;
|
||||
}finally{busy=false;changed();}
|
||||
}
|
||||
return {plan,apply,isBusy:()=>busy};
|
||||
}
|
||||
module.exports={createLibraryOrganization};
|
||||
+215
-54
@@ -20,7 +20,7 @@ function diagnosticSpan(name,attributes,operation){
|
||||
let diagnosticsConfiguration='';
|
||||
function configureDiagnostics(config){const value=config||{enabled:false},context=diagnosticContext(),key=JSON.stringify([value,context]);if(key===diagnosticsConfiguration)return;try{diagnostics.configure(value,context);diagnosticsConfiguration=key;}catch{diagnosticsConfiguration='';try{diagnostics.configure({enabled:false});}catch{}}}
|
||||
function diagnosticCorrelation(){try{const ids=diagnostics.correlation?.();return /^[a-f0-9]{32}$/.test(ids?.traceId||'')&&/[1-9a-f]/.test(ids.traceId)&&/^[a-f0-9]{16}$/.test(ids?.spanId||'')&&/[1-9a-f]/.test(ids.spanId)?{traceId:ids.traceId,spanId:ids.spanId}:{};}catch{return {};}}
|
||||
let win,view,browserSession,managementSession,core,device,policy,ready=false,syncing=false,aiPanel=false,vaultPanel=false,vault,vaultTimer;
|
||||
let win,view,browserSession,managementSession,core,device,policy,ready=false,syncing=false,aiPanel=false,vaultPanel=false,vault,vaultTimer,fileUploadGate,uploadLeaseFatal=false;
|
||||
let chromeHeight=104,leftSidebar=false,startPage=false,overviewPanel=false,toolsOpen=false,pageTitle='',pageColor='#edf1f6';
|
||||
const {createTabRegistry}=require('./tab-registry.cjs');
|
||||
let tabs,tabsRevision=0;
|
||||
@@ -41,7 +41,7 @@ const {createVault}=require('./vault.cjs');
|
||||
const {createDeveloperToolsGuard,developerAssetRequestAllowed}=require('./developer-tools.cjs');
|
||||
const {settingsState,sectionVisible,sectionAllowed,featureAllowed,requireFeature,requireSection,validateAppearance}=require('./managed-settings.cjs');
|
||||
const {bookmarkState,validateMutation:validateBookmarkMutation,libraryVisible}=require('./bookmarks.cjs');
|
||||
let bookmarkBusy=false,personalBookmarks={revision:0,items:[]},bookmarkSnapshotJSON='';
|
||||
let bookmarkBusy=false,personalBookmarks={revision:0,items:[]},bookmarkSnapshotJSON='',libraryOrganizer;
|
||||
function effectiveBookmarkPolicy(){return policy?{...policy,browser:{...policy.browser,bookmarks:{...policy.browser.bookmarks,personal:personalBookmarks}}}:null;}
|
||||
let appearancePreferences={};
|
||||
function managedSettings(){return settingsState({policy,ready,preferences:appearancePreferences});}
|
||||
@@ -94,9 +94,34 @@ async function configureVisualPage(enabled,tab=activeTab()){
|
||||
ipcMain.on('managed:visual-configured',handler);contents.send('managed:visual-config',{enabled,token});
|
||||
});
|
||||
}
|
||||
let companyNews=[],companyNewsKey='',companyNewsFetchedAt=0;
|
||||
let message='Enroll this browser to receive company policy.',syncTimer,localInspector,auditQueue=[],flushing=false,agentService,agentStarting=false,securityCore;
|
||||
const recentRequests=new Map();
|
||||
const agentFile=()=>path.join(app.getPath('userData'),'agent-connection.json');
|
||||
const agentGrants=require('./agent-grants.cjs');
|
||||
const agentActorStorage=new (require('node:async_hooks').AsyncLocalStorage)();
|
||||
let agentServiceMode;
|
||||
function effectiveAgentPolicy(){if(!policy)return policy;const {grants}=agentGrants.reconcileGenerationFloor(policy.agents.grants||[],device?.agentGenerationFloor||{});return {...policy,agents:{...policy.agents,grants}};}
|
||||
function approvedAgentGrants(){return agentGrants.activeGrants(effectiveAgentPolicy());}
|
||||
function actorGrantAllowed(actor,action,url){return agentGrants.authorizeGrant({policy:effectiveAgentPolicy(),ready:ready&&featureAllowed(managedSettings(),'agents'),principalId:actor?.principalId,generation:actor?.generation,action,url,hostMatches:core.hostMatches}).allowed;}
|
||||
function agentRequestOwnerAllowed(source){return policy?.agents?.identityMode!=='individual'||!!source?.guarded;}
|
||||
function actorDestinationAllowed(url,actor=agentActorStorage.getStore()){if(!actor?.action)return core.destinationAllowed(url,policy);return core.destinationAllowed(url,policy)&&actorGrantAllowed(actor,actor.action,url);}
|
||||
async function reconcileAgentPolicy(){
|
||||
const result=agentGrants.reconcileGenerationFloor(policy.agents.grants||[],device.agentGenerationFloor||{});
|
||||
if(JSON.stringify(result.floor)!==JSON.stringify(device.agentGenerationFloor||{})){device.agentGenerationFloor=result.floor;saveDevice(device);}
|
||||
const mode=policy.agents.identityMode;
|
||||
if(agentService&&(agentServiceMode!==mode||!policy.agents.enabled||policy.browser.settings.hiddenSections.includes('agents')||policy.browser.settings.lockedSections.includes('agents'))){agentService.revoke();await agentService.close();agentService=null;agentServiceMode=undefined;}
|
||||
if(mode==='individual'){try{fs.unlinkSync(agentFile());}catch(error){if(error.code!=='ENOENT')throw error;}agentService?.reconcilePrincipals(result.grants);}
|
||||
}
|
||||
async function pairIndividualAgent(principalId){
|
||||
if(typeof principalId!=='string')throw Error('Select an approved agent');
|
||||
const grant=approvedAgentGrants().find(g=>g.id===principalId),initial=policy,service=agentService;
|
||||
if(!grant||!service||agentServiceMode!=='individual')throw Error('Agent grant unavailable');
|
||||
const connection=service.issuePrincipal(grant.id,grant.generation,grant.expiresAt);
|
||||
const credentialPath=await agentGrants.persistConnection({filePath:agentGrants.descriptorPath(app.getPath('userData'),grant.id),connection,protect:value=>require('./dpapi.cjs').capabilityCrypto('protect',value),isCurrent:()=>ready&&policy===initial&&agentService===service&&featureAllowed(managedSettings(),'agents')&&actorGrantAllowed({principalId:grant.id,generation:grant.generation},grant.scopes[0])});
|
||||
return {address:service.address,credentialPath,principalId:grant.id,generation:grant.generation};
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch('disable-http-cache');
|
||||
const uiUrl=pathToFileURL(path.join(__dirname,'index.html')).href;
|
||||
const configPath=()=>path.join(app.getPath('userData'),'enrollment.json');
|
||||
@@ -123,13 +148,18 @@ function managementUrl(value){
|
||||
const managementRequests=new Map();
|
||||
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});
|
||||
}
|
||||
if(route==='/api/device/policy'){
|
||||
if(!device||device.server!==server||device.token!==credential)throw Error('Protected management identity unavailable');
|
||||
return require('./management-channel.cjs').protectedPolicyRequest({device,request,save:saveDevice});
|
||||
}
|
||||
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')?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);
|
||||
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});
|
||||
@@ -137,7 +167,7 @@ async function requestImpl(server,route,body,credential){
|
||||
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);}
|
||||
}
|
||||
function state(){return {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')),agentsEnabled:ready&&!!policy?.agents.enabled,agentConnected:!!agentService};}
|
||||
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}`);}}
|
||||
function layout(){const tab=activeTab();if(!win||!tab)return;view=tab.view;startPage=tab.startPage;const [width,height]=win.getContentSize(),left=leftSidebar?240:0;view.setBounds({x:left,y:chromeHeight,width:Math.max(0,width-left-((aiPanel||vaultPanel)?360:0)),height:Math.max(0,height-chromeHeight)});view.setVisible(ready&&tab.guarded&&!startPage&&!overviewPanel&&!toolsOpen);}
|
||||
async function pageTheme(tab=activeTab()){
|
||||
@@ -147,7 +177,7 @@ async function pageTheme(tab=activeTab()){
|
||||
}catch{}
|
||||
}
|
||||
function lockVault(){const unlocked=vault&&!vault.status().locked;vault?.lock();if(unlocked)audit('vault.locked');emit();}
|
||||
function quarantine(reason){ready=false;if(policy&&Date.parse(policy.expiresAt)<=Date.now())configureDiagnostics({enabled:false});for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();message=reason;vaultPanel=false;vault?.lock();tabs?.quarantine();emit();}
|
||||
function quarantine(reason){ready=false;fileUploadGate?.cancelAll();if(policy&&Date.parse(policy.expiresAt)<=Date.now()){configureDiagnostics({enabled:false});const expiredService=agentService;agentService=null;agentServiceMode=undefined;expiredService?.revoke();void expiredService?.close().catch(()=>{});}for(const [controller,route] of managementRequests)if(route==='/api/device/ai')controller.abort();enforceEvidencePolicy();message=reason;vaultPanel=false;vault?.lock();tabs?.quarantine();emit();}
|
||||
function vaultSettings(){return {enabled:ready&&!!policy?.passwordManager?.enabled,allowSaving:!!policy?.passwordManager?.allowSaving,allowAutofill:!!policy?.passwordManager?.allowAutofill,idleLockSeconds:policy?.passwordManager?.idleLockSeconds||300};}
|
||||
function vaultStatus(){return {...(vault?.status()||{exists:fs.existsSync(path.join(app.getPath('userData'),'password-vault.json')),locked:true}),...vaultSettings()};}
|
||||
function requireVault(){
|
||||
@@ -157,20 +187,33 @@ function requireVault(){
|
||||
if(!vault||vault.status().idleSeconds!==settings.idleLockSeconds){vault?.lock();vault=createVault({filePath:path.join(app.getPath('userData'),'password-vault.json'),idleSeconds:settings.idleLockSeconds,protect:bytes=>safeStorage.encryptString(bytes.toString('base64')),unprotect:bytes=>Buffer.from(safeStorage.decryptString(bytes),'base64')});}
|
||||
return vault;
|
||||
}
|
||||
function persistAudit(){requireEncryption();fs.writeFileSync(path.join(app.getPath('userData'),'audit-queue.bin'),safeStorage.encryptString(JSON.stringify(auditQueue)),{mode:0o600});}
|
||||
function persistAudit(){
|
||||
requireEncryption();const target=path.join(app.getPath('userData'),'audit-queue.bin'),temporary=target+'.'+require('node:crypto').randomUUID()+'.tmp';let fd;
|
||||
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();}}
|
||||
}
|
||||
|
||||
async function flushAudit(){
|
||||
if(!device||flushing||!auditQueue.length)return;flushing=true;
|
||||
try{for(let n=0;n<100&&auditQueue.length;n++){await request(device.server,'/api/device/events',auditQueue[0],device.token);auditQueue.shift();persistAudit();}}
|
||||
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){
|
||||
async function audit(kind,ruleId,host,fileFacts,requestFacts,evidencePayload,agentFacts){
|
||||
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':'unknown'}));}catch{}
|
||||
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;auditQueue.push({eventId:require('node:crypto').randomUUID(),kind,...(ruleId?{ruleId}:{}),...(host?{host}:{}),...(file?{file}:{}),...diagnosticCorrelation(),policyVersion:policy.version});persistAudit();flushAudit();}
|
||||
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;}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
@@ -181,7 +224,7 @@ async function applyPolicyImpl(envelope){
|
||||
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();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 reconcileAgentPolicy();if(Date.parse(policy.expiresAt)>Date.now())configureDiagnostics(policy.diagnostics);emit();return;
|
||||
}
|
||||
configureDiagnostics({enabled:false});quarantine('Applying company policy…');
|
||||
const proxy=next.proxy;
|
||||
@@ -191,9 +234,9 @@ async function applyPolicyImpl(envelope){
|
||||
app.configureHostResolver(resolverOptions(next.enterprise?.dns));
|
||||
await browserSession.clearHostResolverCache();recentRequests.clear();
|
||||
await managementSession.clearHostResolverCache();
|
||||
policy=next;device.minimumVersion=next.version;acceptBookmarks();configureDiagnostics(next.diagnostics);
|
||||
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();}}
|
||||
if((!next.agents.enabled||next.browser.settings.hiddenSections.includes('agents')||next.browser.settings.lockedSections.includes('agents'))&&agentService){agentService.revoke();await agentService.close();agentService=null;try{fs.unlinkSync(agentFile());}catch{}}
|
||||
await reconcileAgentPolicy();
|
||||
await audit('policy.applied');
|
||||
}
|
||||
async function sync(){
|
||||
@@ -210,14 +253,16 @@ async function sync(){
|
||||
}
|
||||
if(Date.parse(policy.expiresAt)<=Date.now())throw Error('Company policy expired');
|
||||
if(clipboardFatal)throw Error('Managed renderer security failed; restart the browser');
|
||||
if(uploadLeaseFatal)throw Error('Upload protection failed; restart the browser');
|
||||
const posture=await diagnosticSpan('browser.network',{operation:'scan'},()=>networkPosture.check(policy.enterprise?.vpn));
|
||||
if(!posture.allowed)throw Error('Required corporate network is unavailable. Connect your company VPN.');
|
||||
managementHealthy=true;
|
||||
const blocked=stickyBlock();
|
||||
if(blocked&&blocked.policyVersion===policy.version&&(blockedTab()?.security.visualBlocked?policy.security.visualPhishing.enabled:policy.security.content.blockedPhrases.length)){message=blocked.message;ready=false;emit();layout();return;}
|
||||
for(const tab of tabs.all()){tab.security.visualBlocked=null;tab.security.contentBlocked=null;}
|
||||
const recovering=!ready;ready=true;tabs.resume(policy.version);message=`Managed · policy v${policy.version}`;emit();layout();
|
||||
const recovering=!ready;ready=true;flushAudit();tabs.resume(policy.version);message=`Managed · policy v${policy.version}`;emit();layout();
|
||||
await ensureAgentAPI();
|
||||
void refreshCompanyNews();
|
||||
if(recovering){for(const tab of tabs.all()){const current=tab.view.webContents.getURL();if(/^https?:\/\//.test(current)&&core.destinationAllowed(current,policy))tab.view.webContents.reload();else if(current!=='about:blank'||tabs.all().length===1)await navigate({url:policy.browser.homeUrl,tabId:tab.id});}emit();}
|
||||
}catch(e){
|
||||
// A deliberate page quarantine aborts in-flight management requests too.
|
||||
@@ -229,6 +274,9 @@ async function sync(){
|
||||
}
|
||||
finally{syncing=false;}
|
||||
}
|
||||
async function refreshCompanyNews(){
|
||||
const p=policy,key=JSON.stringify([p?.version,p?.startPage]);if(!ready||!p?.startPage?.newsEnabled){companyNews=[];companyNewsKey='';return;}if(key===companyNewsKey&&Date.now()-companyNewsFetchedAt<300000)return;companyNewsKey=key;companyNewsFetchedAt=Date.now();companyNews=[];emit();try{const result=await request(device.server,'/api/device/start-page/news',undefined,device.token);if(!ready||policy!==p||result.policyVersion!==p.version)return;companyNews=Array.isArray(result.news)?result.news.slice(0,12).filter(item=>typeof item.title==='string'&&item.title.length<=200&&typeof item.url==='string'&&/^https:\/\//.test(item.url)&&!new URL(item.url).username&&!new URL(item.url).password).map(item=>({title:item.title,url:item.url,publishedAt:item.publishedAt||null})):[];emit();}catch{if(policy===p){companyNews=[];emit();}}
|
||||
}
|
||||
async function completeEnrollment(server,result){
|
||||
if(device)throw Error('This installation is already enrolled');
|
||||
const candidate={server,id:result.id,token:result.token,publicKey:result.publicKey,minimumVersion:0};
|
||||
@@ -247,13 +295,14 @@ async function navigateImpl(input,captured,options={}){
|
||||
const tab=tabs.get(captured?.sessionId||input?.tabId||activeTab().id),contents=tab.view.webContents;
|
||||
if(captured&&(!currentTabContext(captured.token)||captured.webContents!==contents))throw Error('Agent context changed');
|
||||
const url=address.includes('://')?address:`https://${address}`;
|
||||
if(!policy||!['https:','http:'].includes(new URL(url).protocol)||!core.destinationAllowed(url,policy))throw Error('Destination is blocked by company policy');
|
||||
if(!policy||!['https:','http:'].includes(new URL(url).protocol)||!actorDestinationAllowed(url))throw Error('Destination is blocked by company policy');
|
||||
const blocked=stickyBlock(),documentURL=value=>{const parsed=new URL(value);parsed.hash='';return parsed.href;};
|
||||
if(blocked&&managementHealthy&&Date.parse(policy.expiresAt)>Date.now()&&blockedTab()===tab&&documentURL(url)!==documentURL(blocked.checkedURL)){
|
||||
for(const record of tabs.all()){record.security.visualBlocked=null;record.security.contentBlocked=null;}ready=true;tabs.resume(policy.version);message='Checking destination…';
|
||||
}
|
||||
if(!ready)throw Error('Browser is not managed and ready');
|
||||
if(options.signal?.aborted)throw Error('Agent action cancelled');
|
||||
if(!agentActorStorage.getStore()?.principalId&&!captured)tab.security.agentAuthority=null;
|
||||
const initial=policy;tab.startPage=false;if(tab===activeTab())startPage=false;layout();
|
||||
const abort=()=>contents.stop();options.signal?.addEventListener('abort',abort,{once:true});
|
||||
try{await contents.loadURL(url);}finally{options.signal?.removeEventListener('abort',abort);}
|
||||
@@ -262,7 +311,15 @@ async function navigateImpl(input,captured,options={}){
|
||||
if(captured?.token.foreground&&(!refreshed.foreground||captured.token.activation!==refreshed.token.activation))throw Error('Agent foreground changed during navigation');
|
||||
emit();return refreshed||state();
|
||||
}
|
||||
function agentContext(id){const tab=id?tabs.get(id):activeTab(),foreground=tab===activeTab();return {ready:ready&&featureAllowed(managedSettings(),'agents'),interactive:foreground&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,policyVersion:policy?.version,sessionId:tab.id,foreground,token:tabContext(tab.id,{foreground}),webContents:tab.view.webContents,destinationAllowed:core.destinationAllowed};}
|
||||
async function agentHistory(action,captured,{signal}={}){
|
||||
if(!currentTabContext(captured.token)||captured.foreground!==true)throw Error('Agent context changed');
|
||||
const tab=tabs.get(captured.sessionId),contents=tab.view.webContents,initial=policy,activation=captured.token.activation;
|
||||
const current=(phase='after')=>ready&&policy===initial&&!signal?.aborted&&!contents.isDestroyed()&&tabs.fromContents(contents.id)===tab&&activeTab()===tab&&tabContext(tab.id,{foreground:true}).activation===activation&&(phase!=='before'||currentTabContext(captured.token));
|
||||
const allowed=url=>actorDestinationAllowed(url)&&(!initial.agents.allowedHosts.length||initial.agents.allowedHosts.some(pattern=>core.hostMatches(new URL(url).hostname,pattern)));
|
||||
const result=await diagnosticSpan('browser.navigation',{operation:action},()=>require('./history-navigation.cjs').performHistory({contents,action,allowed,current,signal}));
|
||||
if(!current())throw Error('History context changed');emit();return {completed:result.completed,context:agentContext(tab.id)};
|
||||
}
|
||||
function agentContext(id){const tab=id?tabs.get(id):activeTab(),foreground=tab===activeTab();return {ready:ready&&featureAllowed(managedSettings(),'agents'),interactive:foreground&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,policyVersion:policy?.version,sessionId:tab.id,foreground,token:tabContext(tab.id,{foreground}),webContents:tab.view.webContents,destinationAllowed:url=>actorDestinationAllowed(url)};}
|
||||
function managedAI(...args){return diagnosticSpan('browser.ai',{},()=>managedAIImpl(...args));}
|
||||
async function managedAIImpl(prompt){
|
||||
requireFeatureSection('ai');
|
||||
@@ -277,7 +334,7 @@ async function managedAIImpl(prompt){
|
||||
if(!ready||policy.version!==initial.version)throw Error('Policy changed during AI request');return result;
|
||||
}
|
||||
async function mutateManagedBookmarks(input){
|
||||
if(bookmarkBusy||syncing)throw Error('Management update in progress. Try again.');
|
||||
if(bookmarkBusy||syncing||libraryOrganizer?.isBusy())throw Object.assign(Error('Management update in progress. Try again.'),{status:409,code:'browser-management-busy'});
|
||||
const initial=policy,identity=device;
|
||||
const payload=validateBookmarkMutation(input,{policy:effectiveBookmarkPolicy(),ready,settings:managedSettings(),destinationAllowed:core.destinationAllowed});
|
||||
bookmarkBusy=true;
|
||||
@@ -290,11 +347,37 @@ async function mutateManagedBookmarks(input){
|
||||
return state();
|
||||
}finally{bookmarkBusy=false;}
|
||||
}
|
||||
function requireLibraryAI(){
|
||||
requireManagedSection('library');requireFeatureSection('ai');
|
||||
if(!ready||!policy?.ai.enabled||!policy.library?.aiSuggestionsEnabled||Date.parse(policy.expiresAt)<=Date.now())throw Error('AI library organization is disabled by company policy');
|
||||
if(!policy.browser.bookmarks.allowUserEdit)throw Error('Bookmark organization is controlled by your administrator');
|
||||
}
|
||||
function nativeLibraryRows(){return tabs.all().filter(tab=>!tab.startPage&&/^https?:\/\//.test(tab.view.webContents.getURL())).map(tab=>({id:tab.id,title:String(tab.title).replace(/[\u0000-\u001f\u007f]/g,' ').slice(0,120),url:tab.view.webContents.getURL()}));}
|
||||
function nativeLibraryEpoch(){return JSON.stringify(tabs.all().map(tab=>[tab.id,tab.navigationId,tab.title,tab.view.webContents.getURL(),tab.group]));}
|
||||
function assertAgentLibraryDestinations(snapshot){if(agentActorStorage.getStore()?.principalId&&[...(snapshot.tabs||[]),...(snapshot.bookmarks||[])].some(row=>!actorDestinationAllowed(row.url)))throw Error('Agent destination denied');}
|
||||
function getLibraryOrganizer(){
|
||||
if(libraryOrganizer)return libraryOrganizer;
|
||||
libraryOrganizer=require('./library-organization.cjs').createLibraryOrganization({
|
||||
capture:async()=>{const library=await import('../../packages/core/library.mjs');requireLibraryAI();if(bookmarkBusy||syncing)throw Error('Management update in progress');const rows=library.LibraryTabsSchema.parse(nativeLibraryRows());if(agentActorStorage.getStore()?.principalId&&[...rows,...personalBookmarks.items].some(row=>!actorDestinationAllowed(row.url)))throw Error('Agent destination denied');return {policy,identity:device,policyVersion:policy.version,bookmarkRevision:personalBookmarks.revision,bookmarks:structuredClone(personalBookmarks.items),tabs:rows,snapshotHash:library.librarySnapshotHash(rows),epoch:nativeLibraryEpoch()};},
|
||||
assertCurrent:(snapshot,{afterCommit=false,result}={})=>{requireLibraryAI();assertAgentLibraryDestinations(snapshot);if(policy!==snapshot.policy||device!==snapshot.identity||nativeLibraryEpoch()!==snapshot.epoch||personalBookmarks.revision!==snapshot.bookmarkRevision||afterCommit&&(result.version!==policy.version||result.snapshotHash!==snapshot.snapshotHash))throw Error('Library context changed. Generate a fresh proposal.');},
|
||||
inspect:async snapshot=>{const library=await import('../../packages/core/library.mjs');const bytes=Buffer.from(JSON.stringify({bookmarks:library.libraryMetadata(snapshot.bookmarks),tabs:library.libraryMetadata(snapshot.tabs)}));let verdict=core.inspectForAI(bytes,snapshot.policy);if(verdict.decision!=='allow')throw Error('Company policy blocks library metadata');if(snapshot.policy.dlp.semantic.enabled||snapshot.policy.security.yara.enabled){if(!localInspector)throw Error('Local inspector unavailable');verdict=await localInspector.inspect(bytes,snapshot.policy);if(verdict.decision!=='allow'&&!core.allowsConfidentialLocal(verdict,snapshot.policy))throw Error('Local policy blocks library metadata');}verdict=await scanEnterprise(bytes,snapshot.policy,'ai');if(verdict.decision!=='allow')throw Error('Enterprise scanner blocks library metadata');},
|
||||
propose:snapshot=>request(snapshot.identity.server,'/api/device/library/propose',{policyVersion:snapshot.policyVersion,bookmarkRevision:snapshot.bookmarkRevision,tabs:snapshot.tabs,snapshotHash:snapshot.snapshotHash},snapshot.identity.token),
|
||||
commit:async(proposalId,snapshot,automatic)=>{const result=await request(snapshot.identity.server,'/api/device/library/apply',{proposalId,policyVersion:snapshot.policyVersion,expectedRevision:snapshot.bookmarkRevision,snapshotHash:snapshot.snapshotHash,mode:automatic?'automatic':'manual'},snapshot.identity.token);const signed=(await import('../../packages/core/bookmarks.mjs')).verifyBookmarkSnapshot(result.bookmarks,snapshot.identity.publicKey,{deviceId:snapshot.identity.id,policyVersion:snapshot.policyVersion,minimumRevision:snapshot.bookmarkRevision});if(signed.revision!==result.revision||![snapshot.bookmarkRevision,snapshot.bookmarkRevision+1].includes(signed.revision))throw Error('Invalid signed organization revision');if(signed.revision===snapshot.bookmarkRevision&&JSON.stringify(signed.items)!==JSON.stringify(snapshot.bookmarks))throw Error('Bookmarks changed without a new revision');return {...result,verifiedBookmarks:signed};},
|
||||
applyTabs:(assignments,result)=>{for(const row of assignments)tabs.get(row.id);const signed=result.verifiedBookmarks;personalBookmarks={revision:signed.revision,items:signed.items};bookmarkSnapshotJSON=JSON.stringify(signed.items);device.bookmarkRevision=signed.revision;device.bookmarkDigest=require('node:crypto').createHash('sha256').update(bookmarkSnapshotJSON).digest('hex');saveDevice(device);for(const row of assignments)tabs.get(row.id).group=row.group;},
|
||||
autoAllowed:()=>policy?.library?.aiAutoApplyEnabled===true&&policy?.ai?.trustMode==='local',
|
||||
audit,changed:emit
|
||||
});return libraryOrganizer;
|
||||
}
|
||||
async function organizeLibrary(options){return getLibraryOrganizer().plan(options);}
|
||||
async function applyLibraryOrganization(input,options){
|
||||
const result=await getLibraryOrganizer().apply(input,options);
|
||||
await sync();return result;
|
||||
}
|
||||
async function ensureAgentAPI(){
|
||||
if(!ready||!policy.agents.enabled||!featureAllowed(managedSettings(),'agents')||agentService||agentStarting)return;agentStarting=true;
|
||||
try{
|
||||
const getContext=agentContext;
|
||||
const actions=createAgentAdapters({getContext,appControls:agentAppControls(),getSessions:()=>tabs.all().map(tab=>({id:tab.id,title:tab.title,foreground:tab===activeTab()})),contextCurrent:currentTabContext,navigate,ai:managedAI,
|
||||
const actions=createAgentAdapters({getContext,appControls:agentAppControls(),getSessions:()=>tabs.all().filter(tab=>!agentActorStorage.getStore()?.principalId||actorDestinationAllowed(tab.view.webContents.getURL())).map(tab=>({id:tab.id,title:tab.title,foreground:tab===activeTab()})),contextCurrent:currentTabContext,navigate,history:agentHistory,ai:managedAI,
|
||||
inspect:async(text,context)=>{
|
||||
const aiContext=context.action==='ai';
|
||||
let verdict=aiContext?core.inspectForAI(Buffer.from(text),context.policy):core.inspect(Buffer.from(text),context.policy);if(verdict.decision==='block')return verdict;
|
||||
@@ -302,21 +385,31 @@ 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:event=>audit('agent.denied',`${event.action}:${event.outcome}`)});
|
||||
for(const action of ['click','type']){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)=>diagnosticSpan('browser.agent',{operation:action},()=>original(...args));}
|
||||
agentService=await startAgentControl({actions,
|
||||
},audit:auditAgent});
|
||||
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};}
|
||||
return original(args,context);
|
||||
}));}
|
||||
agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[],
|
||||
traceOperation:(operation,fn)=>diagnosticSpan('browser.agent',{operation},fn),
|
||||
onError:error=>{if(!app.isPackaged&&process.env.SAFETYNET_SMOKE_ARTIFACTS)fs.appendFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'smoke-entry.txt'),`Agent action diagnostic: ${error.reason}\n`);},
|
||||
authorize:({action,args,scope})=>{
|
||||
if(!ready||!policy?.agents.enabled||Date.parse(policy.expiresAt)<=Date.now()||!policy.agents.scopes.includes(action))return {allowed:false};
|
||||
if(action==='sessions'||Object.hasOwn(agentAppControls(),action))return {allowed:true,role:'agent',scopes:[scope]};
|
||||
authorize:({action,args,scope,principalId,generation})=>{
|
||||
const actor={principalId,generation,action};
|
||||
if(!actorGrantAllowed(actor,action))return {allowed:false};
|
||||
const checkURL=url=>actorDestinationAllowed(url,actor);
|
||||
if(action==='sessions')return {allowed:true,role:'agent',scopes:[scope]};
|
||||
if(action==='bookmarks:mutate'&&args.bookmark?.url&&!checkURL(args.bookmark.url))return {allowed:false};
|
||||
if(action==='bookmarks:mutate'&&args.action!=='add'){const existing=personalBookmarks.items.find(row=>row.id===(args.id||args.bookmark?.id));if(!existing||!checkURL(existing.url))return {allowed:false};}
|
||||
if(action==='tabs:create'&&args.url&&!checkURL(args.url))return {allowed:false};
|
||||
if(['tabs:activate','tabs:close','tabs:group'].includes(action)){let tab;try{tab=tabs.get(args.sessionId);}catch{return {allowed:false};}const url=tab.view.webContents.getURL();if(url!=='about:blank'&&!checkURL(url))return {allowed:false};}
|
||||
if(Object.hasOwn(agentAppControls(),action))return {allowed:true,role:'agent',scopes:[scope]};
|
||||
let tab;try{tab=args.sessionId?tabs.get(args.sessionId):activeTab();}catch{return {allowed:false};}
|
||||
const url=action==='navigate'?args.url:tab.view.webContents.getURL();
|
||||
if(!core.destinationAllowed(url,policy))return {allowed:false};
|
||||
if(policy.agents.allowedHosts.length&&!policy.agents.allowedHosts.some(pattern=>core.hostMatches(new URL(url).hostname,pattern)))return {allowed:false};
|
||||
if(!checkURL(url))return {allowed:false};
|
||||
return {allowed:true,role:'agent',scopes:[scope]};
|
||||
},audit:event=>audit(event.outcome==='denied'?'agent.denied':'agent.action',`${event.action}:${event.outcome}`),
|
||||
persistCredential:async connection=>{const protectedConnection=await require('./dpapi.cjs').capabilityCrypto('protect',JSON.stringify(connection));fs.writeFileSync(agentFile(),JSON.stringify({format:'windows-dpapi-v1',protectedConnection}),{mode:0o600});}
|
||||
},audit:auditAgent,
|
||||
persistCredential:connection=>agentGrants.persistConnection({filePath:agentFile(),connection,protect:value=>require('./dpapi.cjs').capabilityCrypto('protect',value),isCurrent:()=>ready&&policy?.agents.identityMode==='legacy'&&policy.agents.enabled})
|
||||
});emit();
|
||||
}finally{agentStarting=false;}
|
||||
}
|
||||
@@ -333,15 +426,46 @@ async function dnsGateImpl(url,currentPolicy){
|
||||
function hooks(){
|
||||
browserSession.setPermissionRequestHandler((_wc,_permission,callback)=>callback(false));
|
||||
browserSession.setPermissionCheckHandler(()=>false);
|
||||
const leaseRoot=app.isPackaged?process.resourcesPath:path.join(__dirname,'../..');
|
||||
fileUploadGate=require('./file-upload-gate.cjs').createFileUploadGate({
|
||||
maxBytes:context=>context.policy.dlp.maxBytes,
|
||||
isCurrent:context=>!uploadLeaseFatal&&ready&&policy===context.policy&&Date.parse(context.policy.expiresAt)>Date.now()&&context.pending()&&(!context.source||currentTabContext(context.source)),
|
||||
// Phase callbacks cancel denied requests. Live handles remain held until a
|
||||
// native terminal event; stop()/quarantine alone is not proof of termination.
|
||||
abort:()=>{},
|
||||
createLease:options=>diagnosticSpan('browser.upload.lease',{count:options.paths.length},()=>require('./upload-lease.cjs').acquireUploadLease({...options,
|
||||
pythonPath:path.resolve(leaseRoot,app.isPackaged?'python/python.exe':'.runtime/windows/python/python.exe'),
|
||||
helperPath:path.resolve(leaseRoot,app.isPackaged?'inspector/upload_lease.py':'apps/inspector/upload_lease.py'),
|
||||
onLost:()=>{uploadLeaseFatal=true;options.onLost();quarantine('Upload protection failed. Restart the browser.');diagnosticSpan('browser.upload.fault',{decision:'block',reason:'upload-lease-lost'},()=>audit('upload.block','upload-lease-lost')).catch(()=>{});}})),
|
||||
inspectPart:(bytes,{kind,context,filename})=>diagnosticSpan('browser.upload.part',{bytes:bytes.length},async()=>{
|
||||
const p=context.policy;
|
||||
if(kind==='file'){
|
||||
const transfer=require('../../packages/core/file-transfer.cjs').inspectFileTransfer(bytes,p,'upload',{filename});
|
||||
if(transfer.decision!=='allow')return transfer;
|
||||
}
|
||||
const verdict=core.inspect(bytes,p);
|
||||
if(verdict.decision!=='allow'&&verdict.ruleId!=='uninspectable')return verdict;
|
||||
const needsLocal=verdict.ruleId==='uninspectable'||p.dlp.semantic.enabled||p.security.yara.enabled;
|
||||
if(needsLocal){
|
||||
if(!localInspector)return {decision:'block',ruleId:'inspector-unavailable'};
|
||||
const local=await diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,p));
|
||||
if(local.decision!=='allow'||local.coverage?.complete!==true)return local;
|
||||
}
|
||||
return diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,p,'upload'));
|
||||
})});
|
||||
for(const event of ['onCompleted','onErrorOccurred'])browserSession.webRequest[event]({urls:['<all_urls>']},details=>{
|
||||
if(!fileUploadGate.has(details.id))return;
|
||||
diagnosticSpan('browser.upload.release',{},()=>fileUploadGate.terminal(details.id)).catch(()=>{uploadLeaseFatal=true;quarantine('Upload protection cleanup failed. Restart the browser.');diagnosticSpan('browser.upload.fault',{decision:'block',reason:'upload-lease-release-failed'},()=>audit('upload.block','upload-lease-release-failed')).catch(()=>{});});
|
||||
});
|
||||
const download=createDownloadGate({getContext:id=>{const tab=tabs.fromContents(id);return tab?{ready,policy,webContentsId:id,token:tabContext(tab.id,{foreground:false})}:null;},isCurrent:context=>!!context&¤tTabContext(context.token)&&policy===context.policy,
|
||||
fetchResponse:(url,options)=>diagnosticSpan('browser.request',{operation:'download'},()=>browserSession.fetch(url,options)),scan:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanDownloadEnterprise(bytes,p,localInspector)),
|
||||
fetchResponse:(url,options)=>diagnosticSpan('browser.request',{operation:'download'},()=>browserSession.fetch(url,options)),scan:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanDownloadEnterprise(bytes,p,localInspector?{inspect:(data,pol)=>diagnosticSpan('browser.inspect.local',{bytes:data.length},()=>localInspector.inspect(data,pol)),scanDownload:(data,pol)=>diagnosticSpan('browser.yara',{operation:'scan',bytes:data.length},()=>localInspector.scanDownload(data,pol))}:null)),
|
||||
choosePath:async name=>{const chosen=await dialog.showSaveDialog(win,{title:'Save scanned download (choose a new file)',defaultPath:path.join(app.getPath('downloads'),name)});return chosen.canceled?null:chosen.filePath;},
|
||||
writeFile:async(file,bytes,current)=>{
|
||||
if(!current()||!path.isAbsolute(file)||file.includes('\\\\?\\')||file.slice(2).includes(':'))throw Error('Save path rejected');
|
||||
const handle=await fs.promises.open(file,'wx',0o600);let release=false;
|
||||
try{if(!current())throw Error('Policy changed');await handle.writeFile(bytes);await handle.sync();if(!current())throw Error('Policy changed');release=true;}
|
||||
finally{await handle.close();if(!release)await fs.promises.unlink(file).catch(()=>{});}
|
||||
},audit,status:text=>{message=text;emit();}});
|
||||
},audit:(kind,rule,host,facts,evidence)=>audit(kind,rule,host,facts,undefined,evidence),status:text=>{message=text;emit();}});
|
||||
browserSession.on('will-download',(event,item,contents)=>{
|
||||
const url=item.getURL(),name=item.getFilename(),tracked=recentRequests.get(`${contents?.id}:${url}`);event.preventDefault();
|
||||
if(!/^https?:\/\//.test(url)){audit('download.block','unsupported-download');return;}
|
||||
@@ -353,14 +477,15 @@ function hooks(){
|
||||
if(developerAssetRequestAllowed(details,{ready,policy,tabs:tabs.all()})){callback({cancel:false});return;}
|
||||
let called=false;const finish=cancel=>{if(!called){called=true;callback({cancel});}};
|
||||
const initialPolicy=policy,source=details.webContentsId>0?tabs.fromContents(details.webContentsId):null,sourceContext=source?tabContext(source.id,{foreground:false}):null;
|
||||
const sourceCurrent=()=>!sourceContext||currentTabContext(sourceContext);const timer=setTimeout(()=>finish(true),40000);
|
||||
const sourceCurrent=()=>require('./request-source.cjs').requestSourceCurrent({details,source,token:sourceContext,registry:tabs,currentDocument:currentTabContext});let timer;
|
||||
// Async gates complete before Chromium may transmit the request.
|
||||
diagnosticSpan('browser.request',{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{try{
|
||||
if(!ready||!policy||Date.parse(policy.expiresAt)<=Date.now()||(details.webContentsId>0&&(!source||!source.guarded))){finish(true);return;}
|
||||
if(!core.destinationAllowed(details.url,policy)){audit('navigation.block',null,new URL(details.url).hostname);finish(true);return;}
|
||||
timer=setTimeout(()=>{if(called)return;finish(true);audit('navigation.block','request-inspection-timeout',new URL(details.url).hostname).catch(()=>{});},40000);
|
||||
if(!ready||!policy||Date.parse(policy.expiresAt)<=Date.now()||!agentRequestOwnerAllowed(source)||(details.webContentsId>0&&(!source||!source.guarded))){if(ready&&policy&&(!agentRequestOwnerAllowed(source)||details.webContentsId>0&&(!source||!source.guarded)))await audit('navigation.block','invalid-source-owner',new URL(details.url).hostname);finish(true);return;}
|
||||
if(!core.destinationAllowed(details.url,policy)||(source?.security.agentAuthority&&!actorDestinationAllowed(details.url,source.security.agentAuthority))){audit('navigation.block',null,new URL(details.url).hostname);finish(true);return;}
|
||||
recentRequests.set(`${details.webContentsId}:${details.url}`,{method:details.method,time:Date.now()});if(recentRequests.size>2000)for(const [url,value]of recentRequests)if(value.time<Date.now()-120000||recentRequests.size>2000)recentRequests.delete(url);
|
||||
try{await dnsGate(details.url,initialPolicy);}catch(error){audit('dns.block',error.message,new URL(details.url).hostname);finish(true);return;}
|
||||
if(!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
|
||||
if(called||!ready||policy!==initialPolicy||!sourceCurrent()){if(!called&&ready&&policy===initialPolicy&&!sourceCurrent())await audit('navigation.block','request-source-context-changed',new URL(details.url).hostname);finish(true);return;}
|
||||
if(details.resourceType==='webSocket'&&!policy.browser.allowWebSockets){audit('dlp.block','websocket-disabled',new URL(details.url).hostname);finish(true);return;}
|
||||
const address=new URL(details.url),query=address.pathname+address.search;
|
||||
const queryVerdict=core.inspect(Buffer.from(query),policy);
|
||||
@@ -369,17 +494,25 @@ function hooks(){
|
||||
if(policy.security.visualPhishing.enabled&&(uploads.length||(address.search&&details.resourceType==='xhr')||details.resourceType==='webSocket')){
|
||||
const checked=source?await checkVisual(source):{decision:'unknown'};if(checked.decision!=='allow'||!ready||policy!==initialPolicy||!sourceCurrent()){finish(true);return;}
|
||||
}
|
||||
// Electron file/data-pipe interception cannot bind scanning to the bytes later sent.
|
||||
// Block these paths rather than scanning a mutable file and allowing a race.
|
||||
if(uploads.some(part=>part.type==='file')){
|
||||
const context={policy:initialPolicy,source:sourceContext,pending:()=>!called};
|
||||
const verdict=await fileUploadGate.inspect(details,context);
|
||||
if(verdict.decision!=='allow'){await audit('upload.block',verdict.ruleId||'inspection-incomplete',address.hostname);finish(true);return;}
|
||||
await audit('upload.authorized',null,address.hostname,undefined,verdict.request);
|
||||
finish(uploadLeaseFatal||!ready||policy!==initialPolicy||!sourceCurrent());return;
|
||||
}
|
||||
// Data-pipe/blob bodies still lack an immutable bounded native snapshot.
|
||||
if(uploads.some(part=>!part.bytes || part.file || part.blobUUID)){audit('dlp.block','uninspectable-upload',new URL(details.url).hostname);finish(true);return;}
|
||||
if(!['GET','HEAD','OPTIONS'].includes(details.method)&&!uploads.length){audit('dlp.block','missing-request-body',new URL(details.url).hostname);finish(true);return;}
|
||||
if(uploads.length){
|
||||
if(uploads.reduce((n,p)=>n+p.bytes.length,0)>policy.dlp.maxBytes){audit('dlp.block','size-limit',new URL(details.url).hostname);finish(true);return;}
|
||||
const requestBytes=Buffer.concat(uploads.map(p=>Buffer.from(p.bytes)));
|
||||
const transfer=require('../../packages/core/file-transfer.cjs').inspectFileTransfer(requestBytes,policy,'upload');
|
||||
const fileFacts={bytes:requestBytes.length,detectedType:transfer.detectedType,sha256:transfer.sha256};
|
||||
const releaseUpload=async()=>{await audit('upload.authorized',null,new URL(details.url).hostname,fileFacts);finish(!ready||policy!==initialPolicy||!sourceCurrent());};
|
||||
if(transfer.decision!=='allow'){await audit('upload.block',transfer.ruleId,new URL(details.url).hostname,fileFacts);requestBytes.fill(0);finish(true);return;}
|
||||
// An arbitrary request may contain credentials even when its magic bytes look binary.
|
||||
// Keep size/type for analysts; never fingerprint request bodies in ordinary audit.
|
||||
const requestFacts=require('../../packages/core/request-transfer-facts.cjs').requestFactsForTransfer(transfer);
|
||||
const releaseUpload=async()=>{if(called)return;await audit('upload.authorized',null,new URL(details.url).hostname,undefined,requestFacts);finish(!ready||policy!==initialPolicy||!sourceCurrent());};
|
||||
if(transfer.decision!=='allow'){await audit('upload.block',transfer.ruleId,new URL(details.url).hostname,undefined,requestFacts);requestBytes.fill(0);finish(true);return;}
|
||||
const verdict=core.inspect(requestBytes,policy);requestBytes.fill(0);
|
||||
if(verdict.decision==='block'&&verdict.ruleId!=='uninspectable'){audit('dlp.block',verdict.ruleId,new URL(details.url).hostname);finish(true);return;}
|
||||
if(verdict.ruleId==='uninspectable'||policy.dlp.semantic.enabled||policy.security.yara.enabled||icapRequired(policy,'upload')){
|
||||
@@ -387,7 +520,7 @@ function hooks(){
|
||||
if(needsLocal&&!localInspector){audit('dlp.block','inspector-unavailable',new URL(details.url).hostname);finish(true);return;}
|
||||
const snapshot=Buffer.concat(uploads.map(p=>Buffer.from(p.bytes))),currentPolicy=policy;
|
||||
try{let result=needsLocal?await diagnosticSpan('browser.inspect.local',{bytes:snapshot.length},()=>localInspector.inspect(snapshot,currentPolicy)):{decision:'allow'};if(result.decision==='allow')result=await diagnosticSpan('browser.inspect.enterprise',{bytes:snapshot.length},()=>scanEnterprise(snapshot,currentPolicy,'upload'));
|
||||
const cancel=!ready||policy.version!==currentPolicy.version||!sourceCurrent()||result.decision!=='allow';
|
||||
const cancel=called||!ready||policy.version!==currentPolicy.version||!sourceCurrent()||result.decision!=='allow';
|
||||
if(cancel){audit('dlp.block',result.ruleId||'policy-changed',new URL(details.url).hostname);finish(true);}else await releaseUpload();
|
||||
}catch{audit('dlp.block','inspection-incomplete',new URL(details.url).hostname);finish(true);}finally{snapshot.fill(0);}return;
|
||||
}else{await releaseUpload();return;}
|
||||
@@ -395,14 +528,38 @@ function hooks(){
|
||||
finish(false);
|
||||
}catch{finish(true);}finally{clearTimeout(timer);}}).catch(()=>finish(true));
|
||||
});
|
||||
// Session request and permission gates are installed exactly once.
|
||||
browserSession.webRequest.onBeforeSendHeaders({urls:['<all_urls>']},(details,callback)=>{
|
||||
if(developerAssetRequestAllowed(details,{ready,policy,tabs:tabs.all()})){callback({cancel:false});return;}
|
||||
let called=false;const finish=(cancel,requestHeaders)=>{if(called)return;called=true;callback(cancel?{cancel:true}:{cancel:false,requestHeaders});};
|
||||
const initialPolicy=policy,source=details.webContentsId>0?tabs.fromContents(details.webContentsId):null,sourceContext=source?tabContext(source.id,{foreground:false}):null;
|
||||
const sourceCurrent=()=>require('./request-source.cjs').requestSourceCurrent({details,source,token:sourceContext,registry:tabs,currentDocument:currentTabContext});
|
||||
const current=()=>!called&&ready&&policy===initialPolicy&&Date.parse(initialPolicy?.expiresAt)>Date.now()&&sourceCurrent();
|
||||
let timer;
|
||||
diagnosticSpan('browser.request',{requestType:details.resourceType,...(source?{sessionId:source.id}:{})},async()=>{
|
||||
timer=setTimeout(()=>{if(called)return;finish(true);audit('dlp.block','headers-inspection-timeout',new URL(details.url).hostname).catch(()=>{});},40000);
|
||||
try{
|
||||
if(!current()||!agentRequestOwnerAllowed(source)||(details.webContentsId>0&&(!source||!source.guarded))||!core.destinationAllowed(details.url,initialPolicy)||(source?.security.agentAuthority&&!actorDestinationAllowed(details.url,source.security.agentAuthority))){if(ready&&policy===initialPolicy&&(!agentRequestOwnerAllowed(source)||details.webContentsId>0&&(!source||!source.guarded)))await audit('navigation.block','invalid-source-owner',new URL(details.url).hostname);finish(true);return;}
|
||||
const {snapshotHeaders,inspectHeaders}=require('./header-inspection.cjs');
|
||||
let snapshot;try{snapshot=snapshotHeaders(details.requestHeaders,initialPolicy);}catch(error){await audit('dlp.block',error.message==='headers-size-limit'?'headers-size-limit':'headers-uninspectable',new URL(details.url).hostname);finish(true);return;}
|
||||
const verdict=await diagnosticSpan('browser.request.headers',{bytes:snapshot.bytes,count:snapshot.count,requestType:details.resourceType},()=>inspectHeaders(snapshot,{policy:initialPolicy,inspect:core.inspect,isCurrent:current,
|
||||
inspectLocal:localInspector?(bytes,p)=>diagnosticSpan('browser.inspect.local',{bytes:bytes.length},()=>localInspector.inspect(bytes,p)):null,
|
||||
inspectEnterprise:(bytes,p)=>diagnosticSpan('browser.inspect.enterprise',{bytes:bytes.length},()=>scanEnterprise(bytes,p,'upload'))}));
|
||||
if(!current()){if(ready&&policy===initialPolicy&&!sourceCurrent())await audit('navigation.block','header-source-context-changed',new URL(details.url).hostname);finish(true);return;}
|
||||
if(verdict.decision!=='allow'){await audit('dlp.block',verdict.ruleId||'headers-inspection-incomplete',new URL(details.url).hostname);finish(true);return;}
|
||||
// Chromium receives the exact immutable snapshot that was approved.
|
||||
finish(false,snapshot.headers);
|
||||
}catch{if(current())await audit('dlp.block','headers-inspection-incomplete',new URL(details.url).hostname);finish(true);}
|
||||
finally{clearTimeout(timer);}
|
||||
}).catch(()=>finish(true));
|
||||
});
|
||||
// Session request, header and permission gates are installed exactly once.
|
||||
}
|
||||
function installSiteHooks(tab){
|
||||
const contents=tab.view.webContents,security=tab.security;
|
||||
const developerTools=createDeveloperToolsGuard({contents,getContext:()=>({ready,foreground:tab===activeTab()&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,url:contents.getURL(),token:tabContext(tab.id)}),isCurrent:context=>currentTabContext(context.token),audit:kind=>audit(kind),createFrontend:()=>{const frontend=new BrowserWindow({show:false,width:1000,height:750,title:'S Browser · Privileged developer tools',webPreferences:{session:browserSession,nodeIntegration:false,contextIsolation:true,sandbox:true,devTools:false}});security.developerFrontend=frontend.webContents;frontend.webContents.setWindowOpenHandler(()=>({action:'deny'}));return frontend;}});
|
||||
security.developerToolsGuard=developerTools;
|
||||
contents.setWindowOpenHandler(({url})=>{createNativeTab({url}).catch(e=>{message=e.message;emit();});return {action:'deny'};});
|
||||
contents.on('will-navigate',(event,url)=>{if(!ready||!core.destinationAllowed(url,policy))event.preventDefault();});
|
||||
contents.setWindowOpenHandler(({url})=>{createNativeTab({url,agentAuthority:tab.security.agentAuthority}).catch(e=>{message=e.message;emit();});return {action:'deny'};});
|
||||
contents.on('will-navigate',(event,url)=>{if(!ready||!core.destinationAllowed(url,policy)||(tab.security.agentAuthority&&!actorDestinationAllowed(url,tab.security.agentAuthority)))event.preventDefault();});
|
||||
contents.on('will-attach-webview',event=>event.preventDefault());
|
||||
contents.on('before-input-event',(event,input)=>{if(developerTools.handleInput(event,input))return;const approval=security.visualApproval;if(policy?.security.visualPhishing.enabled&&(!approval||approval.generation!==tab.navigationId||approval.version!==policy.version||approval.url!==contents.getURL())){event.preventDefault();checkVisual(tab).catch(()=>{});}});
|
||||
contents.on('did-start-navigation',(_event,_url,_inPlace,isMainFrame)=>{if(isMainFrame){tabs.navigation(tab.id);if(_url!=='about:blank')tab.startPage=false;}});
|
||||
@@ -420,29 +577,31 @@ function createNativeTab(input={}){return diagnosticSpan('browser.tab',{operatio
|
||||
async function createNativeTabImpl(input={}){
|
||||
if(!input||typeof input!=='object'||Object.keys(input).some(key=>!['group','url'].includes(key)))throw Error('Invalid native tab request');
|
||||
if(input.group)requireManagedSection('library');
|
||||
const tab=tabs.create({group:input.group||''});installSiteHooks(tab);
|
||||
const tab=tabs.create({group:input.group||''});tab.security.agentAuthority=input.agentAuthority||agentActorStorage.getStore()?.principalId&&{...agentActorStorage.getStore()};installSiteHooks(tab);
|
||||
try{
|
||||
await tab.view.webContents.loadURL('about:blank');
|
||||
await diagnosticSpan('browser.clipboard',{operation:'create',sessionId:tab.id},()=>attachPageClipboardGuard(tab.view.webContents,()=>{clipboardFatal=true;quarantine('Managed clipboard protection failed; restart the browser');audit('clipboard.block','clipboard-primitive-unavailable');app.quit();}));
|
||||
tab.guarded=true;selectTab(tab.id);
|
||||
if(input.url)await navigate({url:input.url,tabId:tab.id});emit();return state();
|
||||
if(input.url){if(tab.security.agentAuthority){await agentActorStorage.run(tab.security.agentAuthority,()=>navigate({url:input.url,tabId:tab.id}));}else await navigate({url:input.url,tabId:tab.id});}emit();return state();
|
||||
}catch(error){if(tabs.all().includes(tab)){tabs.remove(tab.id);if(tabs.active()?.guarded)selectTab(tabs.active().id);}throw error;}
|
||||
}
|
||||
|
||||
function updateManagedAppearance(input){requireManagedSection('appearance');appearancePreferences={...appearancePreferences,...validateAppearance(input)};persistAppearance();emit();return managedSettings();}
|
||||
async function closeNativeTab(id){tabs.remove(id);if(!tabs.all().length)await createNativeTab();else selectTab(tabs.active().id);emit();return state();}
|
||||
function groupNativeTab(input){requireManagedSection('library');if(!input||typeof input.group!=='string'||input.group.length>40)throw Error('Invalid native tab group');tabs.get(input.id).group=input.group;emit();return state();}
|
||||
function groupNativeTab(input){requireManagedSection('library');if(libraryOrganizer?.isBusy())throw Error('Library organization is running');if(!input||typeof input.group!=='string'||input.group.length>40)throw Error('Invalid native tab group');tabs.get(input.id).group=input.group;emit();return state();}
|
||||
function agentAppControls(){
|
||||
const checked=(operation)=>async(args,{signal,policyVersion}={})=>{
|
||||
if(signal?.aborted||!ready||policy?.version!==policyVersion||!featureAllowed(managedSettings(),'agents'))throw Error('Managed application changed');
|
||||
return operation(args);
|
||||
return operation(args,{signal});
|
||||
};
|
||||
return {
|
||||
'application:state':checked(()=>({ready,policyVersion:policy.version,activeTabId:activeTab()?.id,tabCount:tabs.all().length})),
|
||||
'settings:get':checked(()=>managedSettings()),
|
||||
'settings:update':checked(args=>updateManagedAppearance(args.appearance)),
|
||||
'bookmarks:list':checked(()=>({...bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()}),policyVersion:policy.version})),
|
||||
'bookmarks:list':checked(()=>{const result=bookmarkState({policy:effectiveBookmarkPolicy(),ready,settings:managedSettings()});if(agentActorStorage.getStore()?.principalId){result.managed=result.managed.filter(row=>actorDestinationAllowed(row.url));result.personal.items=result.personal.items.filter(row=>actorDestinationAllowed(row.url));}return {...result,policyVersion:policy.version};}),
|
||||
'bookmarks:mutate':checked(async args=>{await mutateManagedBookmarks(args);return {completed:true};}),
|
||||
'library:organize-plan':checked((_args,options)=>organizeLibrary(options)),
|
||||
'library:organize-apply':checked((args,options)=>applyLibraryOrganization(args,options)),
|
||||
'tabs:create':checked(async args=>{const result=await createNativeTab(args);return {completed:true,sessionId:result.activeTabId};}),
|
||||
'tabs:activate':checked(args=>{selectTab(args.sessionId);emit();return {completed:true,sessionId:args.sessionId};}),
|
||||
'tabs:close':checked(async args=>{await closeNativeTab(args.sessionId);return {completed:true};}),
|
||||
@@ -461,7 +620,7 @@ app.whenReady().then(async()=>{
|
||||
ipcMain.on('managed:clipboard-sandbox-failure',event=>{if(tabs?.fromContents(event.sender.id)||event.sender===win?.webContents){clipboardFatal=true;quarantine('Managed renderer security failed. Restart this browser.');}});
|
||||
powerMonitor.on('lock-screen',lockVault);powerMonitor.on('suspend',lockVault);
|
||||
securityCore=await import('../../packages/core/security.mjs');
|
||||
localInspector=startInspector(app.isPackaged?process.resourcesPath:path.join(__dirname,'../..'),app.isPackaged);
|
||||
localInspector=startInspector(app.isPackaged?process.resourcesPath:path.join(__dirname,'../..'),app.isPackaged,{onStages:stages=>diagnostics.importStages(stages)});
|
||||
browserSession=session.fromPartition('persist:safetynet-managed',{cache:false});
|
||||
// Management has its own cookie jar and no website request gate. It uses the
|
||||
// same company proxy and Chromium DNS configuration as managed website traffic.
|
||||
@@ -496,6 +655,8 @@ app.whenReady().then(async()=>{
|
||||
ipc('managed:window-control',action=>{if(action==='minimize')win.minimize();else if(action==='maximize'){win.isMaximized()?win.unmaximize():win.maximize();}else if(action==='close')win.close();else throw Error('Unknown window action');});
|
||||
ipc('managed:authorize-setting',section=>{requireManagedSection(section);return {authorized:true,policyVersion:policy.version};});
|
||||
ipc('managed:bookmarks-mutate',mutateManagedBookmarks);
|
||||
ipc('managed:library-organize',organizeLibrary);
|
||||
ipc('managed:library-apply',applyLibraryOrganization);
|
||||
ipc('managed:settings-update',updateManagedAppearance);
|
||||
ipc('managed:chrome-layout',async input=>{
|
||||
if(!input||![64,104].includes(input.height)||['sidebarOpen','startPageOpen','overviewOpen'].some(key=>typeof input[key]!=='boolean'))throw Error('Unsupported browser layout');
|
||||
@@ -573,9 +734,9 @@ app.whenReady().then(async()=>{
|
||||
finally{if(credential){credential.username='';credential.password='';}}
|
||||
});
|
||||
ipc('managed:ai',managedAI);
|
||||
ipc('managed:agent-connection',()=>{
|
||||
ipc('managed:agent-connection',async principalId=>{
|
||||
requireFeatureSection('agents');
|
||||
if(!ready||!policy.agents.enabled||!agentService)throw Error('Agent access is disabled by company policy');return {address:agentService.address,credentialPath:agentFile()};
|
||||
if(!ready||!policy.agents.enabled||!agentService)throw Error('Agent access is disabled by company policy');if(policy.agents.identityMode==='individual')return pairIndividualAgent(principalId);if(principalId!==undefined)throw Error('Individual agent selection requires individual mode');return {address:agentService.address,credentialPath:agentFile()};
|
||||
});
|
||||
ipcMain.on('managed:page-content',async(event,input)=>{
|
||||
const tab=tabs.fromContents(event.sender.id);if(!tab||event.senderFrame!==event.sender.mainFrame||!ready||typeof input?.text!=='string'||input.text.length>250000)return;
|
||||
@@ -607,7 +768,7 @@ app.whenReady().then(async()=>{
|
||||
win.on('resize',layout);win.on('maximize',emit);win.on('unmaximize',emit);win.on('blur',lockVault);win.on('minimize',lockVault);win.on('closed',()=>{vault?.lock();clearInterval(vaultTimer);win=null;clearInterval(syncTimer);localInspector?.stop();agentService?.close();});
|
||||
loadAppearance();
|
||||
await win.loadFile(path.join(__dirname,'index.html'));layout();
|
||||
try{device=loadDevice();const queuePath=path.join(app.getPath('userData'),'audit-queue.bin');if(device&&fs.existsSync(queuePath))auditQueue=JSON.parse(safeStorage.decryptString(fs.readFileSync(queuePath)));if(device){await sync();await audit('browser.started');}}catch(e){quarantine(`Enrollment could not load: ${e.message}`);}
|
||||
try{device=loadDevice();const queuePath=path.join(app.getPath('userData'),'audit-queue.bin');if(device&&fs.existsSync(queuePath)){auditQueue=require('../../packages/core/request-transfer-facts.cjs').migrateQueuedRequestFacts(JSON.parse(safeStorage.decryptString(fs.readFileSync(queuePath))));persistAudit();}if(device){await sync();await audit('browser.started');}}catch(e){quarantine(`Enrollment could not load: ${e.message}`);}
|
||||
const refreshMs=!app.isPackaged&&process.env.SAFETYNET_SMOKE_USERDATA===app.getPath('userData')?1000:15000;
|
||||
syncTimer=setInterval(()=>{if(policy&&Date.parse(policy.expiresAt)<=Date.now())quarantine('Company policy expired');sync();},refreshMs);
|
||||
networkTimer=setInterval(async()=>{
|
||||
@@ -619,7 +780,7 @@ app.whenReady().then(async()=>{
|
||||
let wasLocked=true;vaultTimer=setInterval(()=>{const locked=vault?.status().locked??true;if(locked!==wasLocked){if(locked)audit('vault.locked');wasLocked=locked;emit();}},1000);
|
||||
}).catch(error=>{console.error(error.stack);app.quit();});
|
||||
app.on('window-all-closed',()=>app.quit());
|
||||
app.on('before-quit',()=>{tabs?.close();});
|
||||
app.on('before-quit',()=>{fileUploadGate?.dispose();tabs?.close();});
|
||||
app.on('will-quit',()=>{Promise.resolve(diagnostics.shutdown()).catch(()=>{});vault?.lock();clearInterval(vaultTimer);clearInterval(networkTimer);localInspector?.stop();agentService?.close();});
|
||||
app.on('certificate-error',(event,_wc,_url,_error,_certificate,callback)=>{event.preventDefault();callback(false);});
|
||||
app.on('login',(event,_wc,_details,authInfo,callback)=>{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const channel=require('../../packages/core/policy-channel.cjs');
|
||||
const fs=require('node:fs');
|
||||
function deploymentPin(){if(process.env.SAFETYNET_REQUIRE_CHANNEL_PIN==='true'&&!process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE)throw Error('Deployment requires a protected management signing pin');return process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE?fs.readFileSync(process.env.SAFETYNET_CHANNEL_SIGNATURE_KEY_FILE,'utf8').trim():undefined;}
|
||||
async function protectedPolicyRequest({device,request,save}){
|
||||
async function initializeSigningPin({device,request,save}){
|
||||
const pin=deploymentPin();if(pin&&device.channelSignatureKey&&device.channelSignatureKey.trim()!==pin)throw Error('Protected management signing pin mismatch');
|
||||
if(!device.channelSignatureKey){
|
||||
// One-time legacy migration is authenticated by the enrollment signing pin and HTTPS.
|
||||
@@ -10,10 +10,28 @@ async function protectedPolicyRequest({device,request,save}){
|
||||
channel.verifyIdentity(identity,device.publicKey);if(pin&&identity.signatureKey.trim()!==pin)throw Error('Protected management signing pin mismatch');
|
||||
device.channelSignatureKey=identity.signatureKey;save(device);
|
||||
}
|
||||
}
|
||||
async function protectedPolicyRequest({device,request,save}){
|
||||
await initializeSigningPin({device,request,save});
|
||||
const challenge=await request(device.server,'/api/device/channel/challenge',{},device.token);
|
||||
channel.verifyChallenge(challenge,device.id,device.publicKey,device.channelSignatureKey);
|
||||
const context=channel.prepare(challenge);
|
||||
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);}
|
||||
}
|
||||
module.exports={protectedPolicyRequest,deploymentPin};
|
||||
async function protectedEvidenceRequest({device,request,save,payload}){
|
||||
await initializeSigningPin({device,request,save});
|
||||
const crypto=channel.evidenceChannel;
|
||||
const challenge=await request(device.server,'/api/device/channel/evidence/challenge',{},device.token);
|
||||
crypto.verifyChallenge(challenge,device.id,device.publicKey,device.channelSignatureKey);
|
||||
const context=crypto.prepare(challenge,payload);
|
||||
try{
|
||||
const response=await 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);
|
||||
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);}
|
||||
}
|
||||
module.exports={protectedPolicyRequest,protectedEvidenceRequest,deploymentPin};
|
||||
@@ -1,6 +1,8 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('safetynet', {
|
||||
mutateBookmarks:input=>ipcRenderer.invoke('managed:bookmarks-mutate',input),
|
||||
organizeLibrary:()=>ipcRenderer.invoke('managed:library-organize'),
|
||||
applyLibraryOrganization:input=>ipcRenderer.invoke('managed:library-apply',input),
|
||||
state:()=>ipcRenderer.invoke('managed:state'),
|
||||
authorizeSetting:section=>ipcRenderer.invoke('managed:authorize-setting',section),
|
||||
updateSettings:input=>ipcRenderer.invoke('managed:settings-update',input),
|
||||
@@ -17,7 +19,7 @@ contextBridge.exposeInMainWorld('safetynet', {
|
||||
back:()=>ipcRenderer.invoke('managed:back'),forward:()=>ipcRenderer.invoke('managed:forward'),reload:()=>ipcRenderer.invoke('managed:reload'),
|
||||
ai:prompt=>ipcRenderer.invoke('managed:ai',prompt),
|
||||
inspectFile:()=>ipcRenderer.invoke('managed:inspect-file'),
|
||||
agentConnection:()=>ipcRenderer.invoke('managed:agent-connection'),
|
||||
agentConnection:principalId=>ipcRenderer.invoke('managed:agent-connection',principalId),
|
||||
toggleAI:open=>ipcRenderer.invoke('managed:ai-panel',open),
|
||||
toggleVault:open=>ipcRenderer.invoke('managed:vault-panel',open),
|
||||
vaultStatus:()=>ipcRenderer.invoke('managed:vault-status'),
|
||||
|
||||
+79
-13
@@ -4,12 +4,25 @@ const preference=(key,fallback)=>{try{return JSON.parse(localStorage.getItem(`sb
|
||||
const remember=(key,value)=>{try{localStorage.setItem(`sbrowser.${key}`,JSON.stringify(value));}catch{}};
|
||||
const safeURL=value=>{try{const u=new URL(value);if(!['https:','http:'].includes(u.protocol)||u.username||u.password)return null;u.search='';u.hash='';return u.href;}catch{return null;}};
|
||||
const metadata=list=>Array.isArray(list)?list.slice(0,100).filter(v=>v&&safeURL(v.url)&&typeof v.title==='string').map(v=>({url:safeURL(v.url),title:v.title.slice(0,160)})):[];
|
||||
let bookmarks=[],readingList=metadata(preference('reading-list',[])),groups=preference('groups',[]);groups=Array.isArray(groups)?groups.filter(v=>typeof v==='string'&&v.length<=40).slice(0,20):[];
|
||||
// Company artwork is signed policy data, never a remote image or executable SVG.
|
||||
function renderCompany(s){
|
||||
const name=s.brand?.name==='SafetyNet'?'S Browser':s.brand?.name||'S Browser',logo=typeof s.brand?.logoDataUrl==='string'&&/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(s.brand.logoDataUrl)&&s.brand.logoDataUrl.length<=180000?s.brand.logoDataUrl:null;
|
||||
document.documentElement.style.setProperty('--company-accent',/^#[0-9a-f]{6}$/i.test(s.brand?.color||'')?s.brand.color:'var(--accent)');
|
||||
const setLogo=(image)=>{image.hidden=!logo;if(logo)image.src=logo;else image.removeAttribute('src');image.alt=logo?`${name} logo`:'';};
|
||||
setLogo($('start-company-logo'));$('start-brand-name').textContent=name;
|
||||
const monogram=document.querySelector('.brand-monogram');monogram.replaceChildren();if(logo){const image=document.createElement('img');image.className='company-logo';setLogo(image);monogram.append(image);}else monogram.textContent=name.charAt(0).toUpperCase();
|
||||
for(const id of ['welcome','settings','ai','vault']){const host=$(id);if(!host)continue;let mark=host.querySelector('.company-surface-brand');if(!mark){mark=document.createElement('div');mark.className='company-surface-brand';mark.append(document.createElement('img'),document.createElement('span'));host.prepend(mark);}setLogo(mark.firstElementChild);mark.firstElementChild.className='company-logo';mark.lastElementChild.textContent=name;}
|
||||
const start=s.startPage||{};$('start-title').textContent=start.title||'Your space to explore.';$('start-subtitle').textContent=start.subtitle||'';$('start-subtitle').hidden=!start.subtitle;
|
||||
$('company-news').hidden=!start.newsEnabled;const list=$('company-news-items');list.replaceChildren();
|
||||
if(start.newsEnabled)for(const item of (Array.isArray(start.news)?start.news:[]).slice(0,12)){let url;try{url=new URL(item.url);if(url.protocol!=='https:'||url.username||url.password)continue;}catch{continue;}if(typeof item.title!=='string')continue;const card=button('',()=>navigateTab(url.href),'company-news-card'),title=document.createElement('strong');title.textContent=item.title.slice(0,240);card.append(title);if(item.publishedAt){const date=new Date(item.publishedAt);if(Number.isFinite(date.getTime())){const time=document.createElement('time');time.dateTime=date.toISOString();time.textContent=date.toLocaleDateString();card.append(time);}}list.append(card);}
|
||||
$('company-news-empty').hidden=!!list.children.length;
|
||||
}
|
||||
let bookmarks=[],readingList=metadata(preference('reading-list',[])),groups=[];
|
||||
function currentTab(){return tabs.find(t=>t.id===activeTab)||{id:null,url:'',title:'New Tab',group:'',startPage:true};}
|
||||
async function updateChrome(){
|
||||
document.body.classList.toggle('sidebar-open',sidebarOpen);if($('library'))$('library').hidden=!sidebarOpen;if($('start-page'))$('start-page').hidden=!startPageOpen||overviewOpen||!lastState?.ready;if($('overview'))$('overview').hidden=!overviewOpen||!lastState?.ready;
|
||||
for(const id of ['bookmark','reading-add','share-url'])if($(id))$(id).disabled=!lastState?.ready||startPageOpen||overviewOpen||!currentTab().url||(id==='bookmark'&&(!sectionEditable('library')||!lastState?.bookmarks?.allowUserAdd))||(id==='reading-add'&&!sectionEditable('library'));
|
||||
const height=Math.ceil($('chrome')?.getBoundingClientRect().bottom||104),layout={height,sidebarOpen,startPageOpen:startPageOpen&&!!lastState?.ready,overviewOpen:overviewOpen&&!!lastState?.ready,toolsOpen:!!($('tools-menu')&&!$('tools-menu').hidden)||!!$('bookmark-editor')?.open},key=JSON.stringify(layout);document.documentElement.style.setProperty('--chrome-height',`${height}px`);if(window.safetynet.setChromeLayout&&key!==lastLayoutKey){lastLayoutKey=key;try{await call('setChromeLayout',layout);}catch(e){lastLayoutKey='';throw e;}}
|
||||
const height=Math.ceil($('chrome')?.getBoundingClientRect().bottom||104),layout={height,sidebarOpen,startPageOpen:startPageOpen&&!!lastState?.ready,overviewOpen:overviewOpen&&!!lastState?.ready,toolsOpen:!!($('tools-menu')&&!$('tools-menu').hidden)||!!$('bookmark-editor')?.open||!!$('bookmark-manager')?.open||!!$('agent-pairing')?.open},key=JSON.stringify(layout);document.documentElement.style.setProperty('--chrome-height',`${height}px`);if(window.safetynet.setChromeLayout&&key!==lastLayoutKey){lastLayoutKey=key;try{await call('setChromeLayout',layout);}catch(e){lastLayoutKey='';throw e;}}
|
||||
}
|
||||
async function navigateTab(value){
|
||||
const token=++navigationRevision,tab=currentTab();if(!tab.id)throw Error('No managed tab is available');pendingNavigation={token,tabId:tab.id};overviewOpen=false;
|
||||
@@ -27,7 +40,7 @@ function renderTabs(){
|
||||
const select=button(tab.title||'New Tab',()=>selectTab(tab.id),'tab-label'),close=button('×',()=>closeTab(tab.id),'tab-close');close.setAttribute('aria-label',`Close ${tab.title}`);select.disabled=!lastState?.ready;close.disabled=!lastState?.ready;el.dataset.tabId=tab.id;el.append(select,close);strip.append(el);}
|
||||
if($('overview-grid')){$('overview-grid').replaceChildren();for(const tab of tabs){const card=button('',()=>selectTab(tab.id),'overview-card');const title=document.createElement('strong'),url=document.createElement('span');title.textContent=tab.title;url.textContent=safeURL(tab.url)||'Favorites and a fresh start';card.append(title,url);$('overview-grid').append(card);}}
|
||||
}
|
||||
function renderLinks(id,items){if(!$(id))return;$(id).replaceChildren();for(const item of items){const b=button('',()=>navigateTab(item.url),'favorite-card');const initial=document.createElement('span'),label=document.createElement('strong');initial.className='favorite-icon';initial.textContent=new URL(item.url).hostname.charAt(0).toUpperCase();label.textContent=item.title||new URL(item.url).hostname;if(item.managed){const badge=document.createElement('small');badge.textContent='Company';b.append(badge);}b.title=item.url;b.append(initial,label);$(id).append(b);}}
|
||||
function renderLinks(id,items){if(!$(id))return;$(id).replaceChildren();for(const item of items){const b=button('',()=>navigateTab(item.url),'favorite-card');const initial=document.createElement('span'),label=document.createElement('strong');initial.className='favorite-icon';initial.textContent=new URL(item.url).hostname.charAt(0).toUpperCase();label.textContent=item.title||new URL(item.url).hostname;b.title=item.url;b.append(initial,label);const badge=document.createElement('small');badge.className='favorite-badge';badge.textContent=item.managed?'Company':'';b.append(badge);$(id).append(b);}}
|
||||
let bookmarkEditingId=null;
|
||||
async function openBookmarkEditor(item){
|
||||
requireSection('library',true);const rules=lastState?.bookmarks;
|
||||
@@ -59,9 +72,9 @@ $('bookmark-form').onsubmit=async event=>{event.preventDefault();const submit=$(
|
||||
requireSection('library',true);await mutateBookmark(bookmarkEditingId?'update':'add',{id:bookmarkEditingId||crypto.randomUUID(),title:$('bookmark-title').value,url:$('bookmark-url').value,folder:$('bookmark-folder').value});$('bookmark-editor').close();
|
||||
}catch(e){$('bookmark-editor-error').textContent=e.message;}finally{submit.disabled=false;}};
|
||||
function renderLibrary(){
|
||||
const key=JSON.stringify({bookmarks,bookmarkPermissions:lastState?.bookmarks,settings:lastState?.settings,readingList,groups,tabs,activeTab});if(key===lastLibraryKey)return;lastLibraryKey=key;
|
||||
const favorites=sectionVisible('library')?bookmarks.slice(0,12):[];renderLinks('favorites-grid',favorites);renderLinks('library-favorites',favorites);renderBookmarkLibrary();renderLinks('library-reading',readingList);
|
||||
if($('library-groups')){$('library-groups').replaceChildren();$('library-groups').append(button('All tabs',()=>{groupFilter='';renderTabs();},'group-item'));for(const group of groups)$('library-groups').append(button(group,()=>{groupFilter=group;renderTabs();},'group-item'));}
|
||||
const key=JSON.stringify({bookmarks,bookmarkPermissions:lastState?.bookmarks,libraryAI:lastState?.libraryAI,settings:lastState?.settings,readingList,groups,tabs,activeTab});if(key===lastLibraryKey)return;lastLibraryKey=key;
|
||||
const favorites=sectionVisible('library')?bookmarks.slice(0,12):[];renderLinks('favorites-grid',favorites);renderLinks('library-favorites',favorites);renderBookmarkLibrary();if($('bookmark-manager')?.open){libraryProposal=null;$('manager-proposal').hidden=true;renderBookmarkManager();}renderLinks('library-reading',readingList);
|
||||
if($('library-groups')){$('library-groups').replaceChildren();$('library-groups').append(button('All tabs',()=>{groupFilter='';renderTabs();},'group-item'));for(const group of groups){const row=document.createElement('div');row.className='group-manager-row';row.append(button(group,()=>{groupFilter=group;renderTabs();},'group-item'));if(sectionEditable('library')){const rename=button('Rename',async()=>{const label=$('group-name').value.trim();if(!label||label.length>40)throw Error('Enter a new group name, up to 40 characters');for(const tab of [...tabs].filter(t=>t.group===group))show(await call('groupTab',{id:tab.id,group:label}));groupFilter=label;renderTabs();});rename.setAttribute('aria-label',`Rename group ${group}`);const remove=button('Ungroup',async()=>{for(const tab of [...tabs].filter(t=>t.group===group))show(await call('groupTab',{id:tab.id,group:''}));groupFilter='';renderTabs();});remove.setAttribute('aria-label',`Ungroup tabs in ${group}`);row.append(rename,remove);}$('library-groups').append(row);}}
|
||||
if($('favorites-empty'))$('favorites-empty').hidden=!!favorites.length;
|
||||
if($('library-tabs')){$('library-tabs').replaceChildren();for(const tab of tabs)$('library-tabs').append(button(tab.title,()=>selectTab(tab.id),`library-tab${tab.id===activeTab?' active':''}`));}
|
||||
renderLinks('recent-list',tabs.filter(t=>t.url).slice(-5).map(t=>({url:safeURL(t.url),title:t.title})).filter(t=>t.url));
|
||||
@@ -97,16 +110,17 @@ function applyManagedSettings(){
|
||||
const settings=lastState?.settings,visible=!!settings?.available&&settings.visible;
|
||||
$('tools-toggle').hidden=!visible;$('tools-toggle').disabled=!visible;
|
||||
if(!visible){$('tools-menu').hidden=true;$('tools-toggle').setAttribute('aria-expanded','false');}
|
||||
const sections={appearance:['appearance-section'],library:['bookmark','reading-add','sidebar-toggle'],ai:['ai-toggle','sidebar'],passwords:['passwords'],documentScan:['inspect'],agents:['agents']};
|
||||
const sections={appearance:['appearance-section'],library:['bookmark','reading-add','sidebar-toggle','bookmark-manager-open','favorites-manage'],ai:['ai-toggle','sidebar'],passwords:['passwords'],documentScan:['inspect'],agents:['agents']};
|
||||
for(const [name,ids] of Object.entries(sections))for(const id of ids){const el=$(id);if(!el)continue;el.hidden=!sectionVisible(name);if(!sectionVisible(name)||(['library','ai','passwords','documentScan','agents'].includes(name)&&!sectionEditable(name)))el.disabled=true;}
|
||||
for(const id of ['theme-mode','accent-mode','tab-mode'])$(id).disabled=!sectionEditable('appearance');
|
||||
for(const id of ['bookmark','reading-add','group-add','group-name'])if($(id))$(id).disabled=!sectionEditable('library')||!lastState?.ready||(id==='bookmark'&&!lastState?.bookmarks?.allowUserAdd);
|
||||
$('send').disabled=!lastState?.aiEnabled||!sectionEditable('ai');$('prompt').disabled=!sectionEditable('ai');
|
||||
for(const [quick,original,name,enabled] of [['quick-ai','ai-toggle','ai',lastState?.aiEnabled],['quick-passwords','passwords','passwords',lastState?.ready&&lastState?.passwordManagerEnabled!==false],['quick-inspect','inspect','documentScan',lastState?.documentScanEnabled],['quick-agents','agents','agents',lastState?.agentsEnabled]]){const el=$(quick);el.hidden=visible||!sectionVisible(name)||!enabled;el.disabled=!sectionEditable(name)||!enabled;el.setAttribute('aria-expanded',$(original).getAttribute('aria-expanded')||'false');}
|
||||
$('settings-managed').hidden=!sectionVisible('appearance')||sectionEditable('appearance');
|
||||
if(!sectionVisible('library')){sidebarOpen=false;$('sidebar-toggle').setAttribute('aria-expanded','false');}
|
||||
if(!sectionVisible('library')){if($('bookmark-manager')?.open)$('bookmark-manager').close();sidebarOpen=false;$('sidebar-toggle').setAttribute('aria-expanded','false');}
|
||||
if(!sectionEditable('ai')&&panel)closeAI().catch(()=>{});
|
||||
if(!sectionEditable('passwords')&&vaultPanel)closeVault().catch(()=>{});
|
||||
if($('agent-pairing')?.open&&(!lastState?.ready||!lastState?.agentsEnabled||!sectionEditable('agents')))$('agent-pairing').close();
|
||||
applyAppearance();
|
||||
}
|
||||
|
||||
@@ -118,9 +132,9 @@ async function openAI(){requireSection('ai',true);if(vaultPanel)await closeVault
|
||||
function show(s){
|
||||
// A delayed invoke response must not replace a newer tab switch or navigation event.
|
||||
if(Number.isSafeInteger(s.tabsRevision)&&s.tabsRevision<nativeTabsRevision)return;
|
||||
if(Array.isArray(s.tabs)){nativeTabsRevision=s.tabsRevision??nativeTabsRevision;tabs=s.tabs;activeTab=s.activeTabId;startPageOpen=!!currentTab().startPage;if(groupFilter&¤tTab().group!==groupFilter)groupFilter=currentTab().group;}
|
||||
if(Array.isArray(s.tabs)){nativeTabsRevision=s.tabsRevision??nativeTabsRevision;tabs=s.tabs;groups=[...new Set(tabs.map(t=>t.group).filter(Boolean))];activeTab=s.activeTabId;startPageOpen=!!currentTab().startPage;if(groupFilter&¤tTab().group!==groupFilter)groupFilter=currentTab().group;}
|
||||
document.body.classList.toggle('window-maximized',!!s.windowMaximized);
|
||||
lastState=s;bookmarks=[...(s.bookmarks?.managed||[]).map(item=>({...item,managed:true})),...(s.bookmarks?.personal?.items||[])];if(!s.ready||!sectionEditable('library')||($('bookmark-editor').open&&!(bookmarkEditingId?s.bookmarks?.allowUserEdit:s.bookmarks?.allowUserAdd)))$('bookmark-editor').close();const brand=s.brand.name==='SafetyNet'?'S Browser':s.brand.name;$('brand').textContent=brand;document.title=brand;$('managed').textContent=s.ready?(s.loginCheckPending?'Login check required':`Managed · policy ${s.policyVersion}`):'Access paused';$('managed').parentElement.classList.toggle('paused',!s.ready);
|
||||
lastState=s;renderCompany(s);bookmarks=[...(s.bookmarks?.managed||[]).map(item=>({...item,managed:true})),...(s.bookmarks?.personal?.items||[])];if(!s.ready||!sectionEditable('library')||($('bookmark-editor').open&&!(bookmarkEditingId?s.bookmarks?.allowUserEdit:s.bookmarks?.allowUserAdd)))$('bookmark-editor').close();const brand=s.brand.name==='SafetyNet'?'S Browser':s.brand.name;$('brand').textContent=brand;document.title=brand;$('managed').textContent=s.ready?(s.loginCheckPending?'Login check required':`Managed · policy ${s.policyVersion}`):'Access paused';$('managed').parentElement.classList.toggle('paused',!s.ready);
|
||||
$('welcome').hidden=s.ready;$('enrollment').hidden=s.enrolled;$('message').textContent=s.message;
|
||||
if(document.activeElement!==$('address'))$('address').value=startPageOpen?'':s.url||'';
|
||||
$('managed').title=s.message;$('address-form').querySelector('.address-lock').src=s.url?.startsWith('https:')?'icons/lock-keyhole.svg':'icons/globe.svg';
|
||||
@@ -135,7 +149,7 @@ function show(s){
|
||||
if($('start-greeting'))$('start-greeting').textContent='A fresh place to begin';
|
||||
if($('sidebar-toggle'))$('sidebar-toggle').disabled=!s.ready;
|
||||
if($('new-tab'))$('new-tab').disabled=!s.ready;if($('tab-overview'))$('tab-overview').disabled=!s.ready;
|
||||
applyManagedSettings();updateChrome().catch(()=>{});renderTabs();renderLibrary();
|
||||
reconcileAgentPairing();applyManagedSettings();updateChrome().catch(()=>{});renderTabs();renderLibrary();
|
||||
}
|
||||
function task(fn){return async event=>{event?.preventDefault();$('error').textContent='';try{await fn();}catch(e){$('error').textContent=e.message;$('managed').textContent=e.message;}};}
|
||||
function vaultTask(fn){return async event=>{event?.preventDefault();if(vaultBusy)return;vaultBusy=true;$('vault-error').textContent='';try{await fn();}catch{$('vault-error').textContent='The vault action could not be completed. Check your password and company policy.';}finally{vaultBusy=false;$('vault-master').value='';$('vault-confirm').value='';}};}
|
||||
@@ -150,7 +164,43 @@ $('inspect').onclick=task(async()=>{
|
||||
requireSection('documentScan',true);$('inspect').disabled=true;try{const result=await call('inspectFile');if(!result)return;await openAI();entry(`Inspection: ${result.name}\nDecision: ${result.decision}\nDetector: ${result.ruleId||'No match'}\nType: ${result.contentType}\nComplete coverage: ${result.coverage.complete}\n${result.model?`Model: ${result.model}`:''}\nAnalyzed locally. This result does not authorize a website upload.`);}finally{show(await call('state'));}
|
||||
});
|
||||
function entry(text){const empty=$('conversation').querySelector('.empty-chat');empty?.remove();const e=document.createElement('div');e.className='entry';e.textContent=text;$('conversation').append(e);e.scrollIntoView();}
|
||||
$('agents').onclick=task(async()=>{requireSection('agents',true);const connection=await call('agentConnection');await openAI();entry(`Local agent API\nEndpoint: ${connection.address}\nEncrypted pairing file: ${connection.credentialPath}\nUse the trusted local helper. Every action follows company policy.`);});
|
||||
// Pairing selects a signed central identity. It never creates a local grant or exposes tokens.
|
||||
let agentPairingRevision=0,agentPairingFingerprint='';
|
||||
function agentGrantFingerprint(){return JSON.stringify({mode:lastState?.agentIdentityMode,grants:approvedAgentGrants()});}
|
||||
function reconcileAgentPairing(){if($('agent-pairing')?.open&&agentPairingFingerprint!==agentGrantFingerprint())$('agent-pairing').close();}
|
||||
setInterval(reconcileAgentPairing,1000);
|
||||
function approvedAgentGrants(){return (Array.isArray(lastState?.agentGrants)?lastState.agentGrants:[]).filter(g=>g&&typeof g.id==='string'&&typeof g.name==='string'&&Number.isSafeInteger(g.generation)&&g.generation>=1&&Date.parse(g.expiresAt)>Date.now());}
|
||||
function agentPairingDialog(){
|
||||
let dialog=$('agent-pairing');if(dialog)return dialog;
|
||||
dialog=document.createElement('dialog');dialog.id='agent-pairing';dialog.setAttribute('aria-labelledby','agent-pairing-title');
|
||||
const title=document.createElement('h2');title.id='agent-pairing-title';title.textContent='Connect a local agent';
|
||||
const description=document.createElement('p');description.id='agent-pairing-description';dialog.setAttribute('aria-describedby',description.id);
|
||||
const list=document.createElement('div');list.id='agent-grant-list';list.className='agent-grant-list';
|
||||
const result=document.createElement('pre');result.id='agent-pairing-result';result.setAttribute('role','status');result.setAttribute('aria-live','polite');
|
||||
const close=button('Close',()=>dialog.close(),'secondary');close.setAttribute('aria-label','Close agent connection');
|
||||
dialog.append(title,description,list,result,close);document.body.append(dialog);
|
||||
dialog.addEventListener('close',()=>{agentPairingRevision++;agentPairingFingerprint='';list.replaceChildren();result.textContent='';updateChrome().catch(()=>{});});
|
||||
return dialog;
|
||||
}
|
||||
async function openAgentPairing(){
|
||||
requireSection('agents',true);if(!lastState?.ready||!lastState?.agentsEnabled)throw Error('Local agent control is disabled by your administrator.');
|
||||
const dialog=agentPairingDialog(),revision=++agentPairingRevision,mode=lastState.agentIdentityMode==='individual'?'individual':'legacy',list=$('agent-grant-list'),result=$('agent-pairing-result');list.replaceChildren();result.textContent='';
|
||||
$('agent-pairing-description').textContent=mode==='individual'?'Choose an identity approved by your administrator. Each identity has its own permissions and encrypted pairing file.':'Shared legacy credential: all connected agents share the same permissions. Your administrator can require individual identities.';
|
||||
const pair=async grant=>{
|
||||
requireSection('agents',true);if(revision!==agentPairingRevision)return;
|
||||
if(mode==='individual'&&!approvedAgentGrants().some(g=>g.id===grant.id&&g.generation===grant.generation))throw Error('This agent identity is no longer approved.');
|
||||
const connection=await call('agentConnection',grant?.id);
|
||||
requireSection('agents',true);if(revision!==agentPairingRevision||!dialog.open)return;
|
||||
if(!lastState?.ready||!lastState?.agentsEnabled||(lastState.agentIdentityMode==='individual'?'individual':'legacy')!==mode||(grant&&!approvedAgentGrants().some(g=>g.id===grant.id&&g.generation===grant.generation)))throw Error('Agent permissions changed. Open the connection dialog again.');
|
||||
result.textContent=`${grant?`Identity: ${grant.name}\n`:'Shared legacy credential\n'}Endpoint: ${connection.address}\nEncrypted pairing file: ${connection.credentialPath}\nUse the trusted local helper. Every action follows company policy.`;
|
||||
};
|
||||
if(mode==='individual'){
|
||||
const grants=approvedAgentGrants();if(!grants.length){const empty=document.createElement('p');empty.textContent='No active agent identities are approved. Ask your administrator to publish an agent grant.';list.append(empty);}
|
||||
for(const grant of grants){const choice=button('',()=>pair(grant),'agent-grant-choice'),name=document.createElement('strong'),detail=document.createElement('small');name.textContent=grant.name;detail.textContent=`Expires ${new Date(grant.expiresAt).toLocaleString()} · generation ${grant.generation}`;choice.append(name,detail);list.append(choice);}
|
||||
}else list.append(button('Get shared legacy connection',()=>pair(null),'agent-grant-choice'));
|
||||
agentPairingFingerprint=agentGrantFingerprint();dialog.showModal();await updateChrome();
|
||||
}
|
||||
$('agents').onclick=task(openAgentPairing);
|
||||
$('ai-form').onsubmit=task(async()=>{const prompt=$('prompt').value;if(!prompt.trim())return;entry(`You: ${prompt}`);$('send').disabled=true;try{const result=await call('ai',prompt);entry(result.answer);$('prompt').value='';}catch(e){entry(e.message);}finally{$('send').disabled=!lastState?.aiEnabled;}});
|
||||
async function refreshVault(){
|
||||
const revision=++vaultRevision;const status=await call('vaultStatus');if(!vaultPanel||revision!==vaultRevision)return;const previous=vaultState;vaultState=status;
|
||||
@@ -183,7 +233,7 @@ for(const action of ['minimize','maximize','close'])wire(`window-${action}`,()=>
|
||||
wire('share-url',()=>call('shareURL'));
|
||||
wire('bookmark',async()=>{requireSection('library',true);if(!lastState?.bookmarks?.allowUserAdd)throw Error('Adding bookmarks is disabled by your administrator');await openBookmarkEditor();});
|
||||
wire('reading-add',async()=>{requireSection('library',true);await call('authorizeSetting','library');requireSection('library',true);if(!lastState?.ready)throw Error('Open an approved page first');const url=safeURL(lastState.url);if(!url)throw Error('This page cannot be saved');if(!readingList.some(b=>b.url===url)){readingList.push({url,title:String(lastState.pageTitle||new URL(url).hostname).slice(0,160)});readingList=readingList.slice(-100);remember('reading-list',readingList);renderLibrary();}});
|
||||
wire('group-add',async()=>{requireSection('library',true);await call('authorizeSetting','library');requireSection('library',true);const field=$('group-name'),label=field?.value.trim();if(!label)throw Error('Enter a tab group name');if(label.length>40)throw Error('Tab group names are limited to 40 characters');if(!groups.includes(label)){if(groups.length>=20)throw Error('At most 20 tab groups are supported');groups.push(label);remember('groups',groups);}show(await call('groupTab',{id:currentTab().id,group:label}));groupFilter=label;field.value='';renderLibrary();renderTabs();});
|
||||
wire('group-add',async()=>{requireSection('library',true);await call('authorizeSetting','library');requireSection('library',true);const field=$('group-name'),label=field?.value.trim();if(!label)throw Error('Enter a tab group name');if(label.length>40)throw Error('Tab group names are limited to 40 characters');if(!groups.includes(label)){if(groups.length>=20)throw Error('At most 20 tab groups are supported');groups.push(label);}show(await call('groupTab',{id:currentTab().id,group:label}));groupFilter=label;field.value='';renderLibrary();renderTabs();});
|
||||
for(const [quick,original] of [['quick-ai','ai-toggle'],['quick-passwords','passwords'],['quick-inspect','inspect'],['quick-agents','agents']])wire(quick,()=>$(original).click());
|
||||
wire('tools-toggle',async()=>{if(!lastState?.settings?.available||!lastState.settings.visible)throw Error('Settings are managed by your administrator.');const menu=$('tools-menu');if(menu){menu.hidden=!menu.hidden;$('tools-toggle').setAttribute('aria-expanded',String(!menu.hidden));await updateChrome();}});
|
||||
for(const id of ['theme-mode','accent-mode','tab-mode'])$(id).onchange=task(async()=>{
|
||||
@@ -196,3 +246,19 @@ document.addEventListener('click',event=>{const menu=$('tools-menu');if(!menu||m
|
||||
window.addEventListener('keydown',event=>{if(event.ctrlKey&&event.key.toLowerCase()==='t'&&lastState?.ready){event.preventDefault();newTab().catch(()=>{});}if(event.key==='Escape'&&overviewOpen){overviewOpen=false;startPageOpen=!!currentTab().startPage;updateChrome().catch(()=>{});}});
|
||||
applyAppearance();renderTabs();renderLibrary();
|
||||
window.safetynet.onState(show);call('state').then(show).catch(e=>{$('error').textContent=e.message;});
|
||||
|
||||
let libraryProposal=null;
|
||||
function renderBookmarkManager(){
|
||||
const query=$('bookmark-search').value.trim().toLowerCase(),list=$('manager-list');list.replaceChildren();
|
||||
const matches=bookmarks.filter(item=>[item.title,item.url,item.folder||''].some(value=>value.toLowerCase().includes(query)));
|
||||
let section='';for(const item of matches){const key=`${item.managed?'Company':'Personal'} - ${item.folder||'Unfiled'}`;if(key!==section){const label=document.createElement('h3');label.className='manager-folder';label.textContent=key;list.append(label);section=key;}
|
||||
const row=document.createElement('div');row.className='manager-row';row.dataset.bookmarkId=item.id;const link=button('',()=>navigateTab(item.url),'manager-link'),title=document.createElement('strong'),detail=document.createElement('small');title.textContent=item.title;detail.textContent=item.url;link.append(title,detail);row.append(link);
|
||||
if(item.managed){const badge=document.createElement('span');badge.className='manager-managed';badge.textContent='Managed';row.append(badge);}else if(sectionEditable('library')){const actions=document.createElement('div');actions.className='manager-row-actions';if(lastState.bookmarks.allowUserEdit)actions.append(button('Edit / move',()=>openBookmarkEditor(item)));if(lastState.bookmarks.allowUserDelete)actions.append(button('Remove',async()=>{await mutateBookmark('delete',null,item.id);renderBookmarkManager();}));row.append(actions);}list.append(row);}
|
||||
$('manager-empty').hidden=matches.length>0;$('manager-add').disabled=!sectionEditable('library')||!lastState?.bookmarks?.allowUserAdd;$('manager-ai').disabled=!sectionEditable('library')||!lastState?.libraryAI?.suggestionsEnabled||!!lastState?.libraryAI?.busy;
|
||||
$('manager-policy').textContent=sectionEditable('library')?'Company bookmarks are read-only. Personal bookmarks follow company policy. AI organization shares titles and website domains with your approved model.':'Your administrator manages this library. Changes are disabled.';
|
||||
}
|
||||
async function openBookmarkManager(){requireSection('library');$('tools-menu').hidden=true;renderBookmarkManager();$('bookmark-manager').showModal();await updateChrome();$('bookmark-search').focus();}
|
||||
for(const id of ['bookmark-manager-open','favorites-manage'])wire(id,openBookmarkManager);
|
||||
$('bookmark-search').addEventListener('input',renderBookmarkManager);$('bookmark-manager-close').onclick=()=>$('bookmark-manager').close();$('bookmark-manager').addEventListener('close',()=>updateChrome().catch(()=>{}));wire('manager-add',()=>openBookmarkEditor());
|
||||
wire('manager-ai',async()=>{requireSection('library',true);libraryProposal=await call('organizeLibrary');const diff=$('manager-diff');diff.replaceChildren();for(const [index,item] of (libraryProposal.plan.bookmarks||[]).entries()){const existing=bookmarks.find(b=>b.id===item.id);if(!existing||existing.managed)throw Error('Organization proposal no longer matches your library');const row=document.createElement('p');row.textContent=`${index+1}. ${existing.title}: ${existing.folder||'Unfiled'} -> ${item.folder||'Unfiled'}`;diff.append(row);}for(const item of libraryProposal.plan.tabs||[]){const existing=tabs.find(t=>t.id===item.id);if(!existing)throw Error('Organization proposal no longer matches your tabs');const row=document.createElement('p');row.textContent=`Tab ${existing.title}: ${existing.group||'Ungrouped'} -> ${item.group||'Ungrouped'}`;diff.append(row);}$('manager-proposal').hidden=false;});
|
||||
wire('manager-apply',async()=>{if(!libraryProposal)throw Error('Request a fresh organization proposal');await call('applyLibraryOrganization',{proposalId:libraryProposal.proposalId,confirm:true});libraryProposal=null;$('manager-proposal').hidden=true;show(await call('state'));renderBookmarkManager();});$('manager-discard').onclick=()=>{libraryProposal=null;$('manager-proposal').hidden=true;};
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
// A new top-level GET belongs to the live native tab, not the document it leaves.
|
||||
// Data-bearing requests still belong to the exact original document generation.
|
||||
function requestSourceCurrent({details,source,token,registry,currentDocument}){
|
||||
try{
|
||||
if(!token)return !(details.webContentsId>0);
|
||||
if(details.resourceType==='mainFrame'&&details.method==='GET'&&!(details.uploadData?.length)){
|
||||
const contents=token.webContents;
|
||||
return !!source&&source.guarded===true&&source.view.webContents===contents&&!contents.isDestroyed()&®istry.fromContents(details.webContentsId)===source;
|
||||
}
|
||||
return currentDocument(token);
|
||||
}catch{return false;}
|
||||
}
|
||||
module.exports={requestSourceCurrent};
|
||||
@@ -1682,3 +1682,37 @@ body[data-theme=dark] .favorite-icon {
|
||||
/* Company links remain readable when the library is locked; mutation controls disappear. */
|
||||
.bookmark-row{display:flex;align-items:center;gap:4px;margin:4px 0}.bookmark-link{flex:1;min-width:0;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bookmark-action{font-size:10px;padding:5px}.bookmark-section-label{font-size:11px;font-weight:600;opacity:.7;margin:12px 0 6px}
|
||||
#bookmark-editor{color:var(--text,#202632);background:var(--surface,#edf1f6);border:1px solid #8885;border-radius:22px;padding:24px;width:min(420px,80vw);box-shadow:0 24px 80px #0005}#bookmark-editor::backdrop{background:#17233b55;backdrop-filter:blur(8px)}#bookmark-editor label{display:block;margin:12px 0}#bookmark-editor input{display:block;width:100%;box-sizing:border-box;margin-top:5px}#bookmark-editor-error{color:#d53939;font-size:12px}.bookmark-notice{font-size:12px;opacity:.75}
|
||||
|
||||
/* Fixed icon/title/badge rows prevent a managed bookmark from shifting its icon. */
|
||||
#favorites-grid .favorite-card {display:grid;grid-template-rows:64px 18px 14px;gap:8px;justify-items:center;align-content:start;min-width:0;align-self:start;}
|
||||
.company-logo {display:block;max-width:100%;object-fit:contain;}
|
||||
.brand-monogram .company-logo {width:36px;height:36px;}
|
||||
.start-company-logo {width:auto;max-width:260px;height:72px;margin:0 0 20px;object-position:left center;}
|
||||
.company-surface-brand {display:flex;align-items:center;gap:9px;color:var(--muted);font-size:12px;font-weight:600;padding:12px 16px;}
|
||||
.company-surface-brand .company-logo {width:28px;height:28px;}
|
||||
#welcome>.company-surface-brand {position:absolute;top:24px;left:24px;}
|
||||
.start-subtitle {color:var(--muted);font-size:16px;line-height:1.5;margin:10px 0 28px;max-width:650px;}
|
||||
.company-news-items {display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,230px),1fr));gap:12px;}
|
||||
.company-news-card {display:flex;flex-direction:column;text-align:left;align-items:flex-start;gap:10px;border-radius:16px;padding:18px;border:1px solid var(--line);background:color-mix(in srgb,var(--surface,#fff) 65%,transparent);color:inherit;min-height:100px;transition:transform .2s,box-shadow .2s;}
|
||||
.company-news-card:hover {transform:translateY(-2px);box-shadow:0 8px 24px #0000000c;}
|
||||
.company-news-card:hover strong {color:var(--company-accent,var(--accent));}
|
||||
#start-brand-name {color:var(--company-accent,var(--accent));}
|
||||
.company-news-card strong {font-size:14px;line-height:1.5;overflow-wrap:anywhere;}
|
||||
.company-news-card time,.company-news-empty {font-size:12px;color:var(--muted);}
|
||||
#favorites-grid .favorite-card .favorite-icon {grid-row:1;width:64px;height:64px;}
|
||||
#favorites-grid .favorite-card strong {grid-row:2;line-height:18px;max-width:100%;}
|
||||
#favorites-grid .favorite-badge {grid-row:3;line-height:14px;color:var(--muted);}
|
||||
.library-links .favorite-badge,.recent-list .favorite-badge {display:none;}
|
||||
.text-button {background:transparent;color:var(--accent-text);border:0;font-size:12px;padding:6px 0;cursor:pointer;}
|
||||
#bookmark-manager {width:min(780px,calc(100vw - 36px));max-height:calc(100vh - 80px);box-sizing:border-box;padding:26px;border:1px solid #8884;border-radius:24px;background:var(--surface,#edf1f6);color:var(--text);box-shadow:0 24px 90px #0005;overflow:auto;}
|
||||
#bookmark-manager::backdrop {background:#17233b55;backdrop-filter:blur(10px);}
|
||||
.manager-header,.manager-toolbar,.manager-row {display:flex;gap:12px;align-items:center;}
|
||||
.manager-header {justify-content:space-between;margin-bottom:20px;}.manager-header h2{margin:4px 0;font-size:26px;}.manager-eyebrow{margin:0;font-size:10px;letter-spacing:.14em;color:var(--muted);}
|
||||
.manager-toolbar{flex-wrap:wrap;}.manager-toolbar input{flex:1;min-width:180px;}.manager-list{margin-top:18px;}.manager-row{padding:12px 0;border-bottom:1px solid #8882;}.manager-row .manager-link{flex:1;min-width:0;text-align:left;background:transparent;}.manager-link strong,.manager-link small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.manager-link small{color:var(--muted);font-size:11px;margin-top:4px;}.manager-row button{flex-shrink:0;}.manager-folder{margin:22px 0 4px;font-size:12px;color:var(--muted);}.manager-managed{font-size:10px;color:var(--muted);}.manager-row-actions{display:flex;gap:6px;}#manager-error{color:#d53939;font-size:12px;}
|
||||
@media(max-width:600px){.manager-row{flex-wrap:wrap;}.manager-row-actions{width:100%;justify-content:flex-end;}#bookmark-manager{padding:18px;}.toolbar{gap:6px;}}
|
||||
|
||||
.group-manager-row{display:flex;gap:4px;align-items:center;flex-wrap:wrap;}.group-manager-row .group-item{flex:1;min-width:80px;}.group-manager-row>button:not(.group-item){font-size:10px;padding:5px;}
|
||||
|
||||
#bookmark-manager .manager-link{color:var(--text);padding:8px 12px;}#bookmark-manager .manager-row-actions button{background:color-mix(in srgb,var(--accent) 10%,transparent);color:var(--accent-text);}#bookmark-manager{backdrop-filter:blur(24px) saturate(150%);}
|
||||
|
||||
#agent-pairing{color:var(--text,#202632);background:var(--surface,#edf1f6);border:1px solid #8885;border-radius:22px;padding:24px;width:min(520px,85vw);box-shadow:0 24px 80px #0005}#agent-pairing::backdrop{background:#17233b55;backdrop-filter:blur(8px)}.agent-grant-list{display:grid;gap:10px;margin:16px 0}.agent-grant-choice{text-align:left;padding:12px;border-radius:12px}#agent-pairing pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
const {spawn}=require('node:child_process');
|
||||
const path=require('node:path');
|
||||
const crypto=require('node:crypto');
|
||||
const fs=require('node:fs');
|
||||
const VERSION='safetynet-upload-lease-v1';
|
||||
const LIMIT=10*1024*1024;
|
||||
let disabled=false;
|
||||
function trustedFile(p){
|
||||
if(typeof p!=='string'||!path.win32.isAbsolute(p)||p.startsWith('\\\\'))return false;
|
||||
try{const real=fs.realpathSync.native(p);return real.toLowerCase()===p.toLowerCase()&&fs.statSync(p).isFile();}catch{return false;}
|
||||
}
|
||||
function refused(){return Object.assign(new Error('Upload lease unavailable'),{code:'upload-lease-refused'});}
|
||||
async function acquireUploadLease({paths,maxBytes=LIMIT,pythonPath,helperPath,onLost=()=>{},signal}){
|
||||
if(process.platform!=='win32'||!Array.isArray(paths)||paths.length<1||paths.length>16||!Number.isInteger(maxBytes)||maxBytes<1||maxBytes>LIMIT||typeof onLost!=='function'||![pythonPath,helperPath].every(p=>typeof p==='string'&&path.win32.isAbsolute(p)&&!p.startsWith('\\\\'))||!paths.every(p=>typeof p==='string'&&/^[A-Za-z]:\\[^\x00-\x1f:]*$/.test(p)&&p.length<=240&&path.win32.normalize(p)===p))throw refused();
|
||||
if(disabled||signal?.aborted||!trustedFile(pythonPath)||!trustedFile(helperPath))throw refused();
|
||||
const child=spawn(pythonPath,['-I','-B',helperPath],{windowsHide:true,stdio:['pipe','pipe','pipe'],env:{SystemRoot:process.env.SystemRoot,WINDIR:process.env.WINDIR},shell:false});
|
||||
let live=true,releasing=false,ready=false,buffer=Buffer.alloc(0),total=0,settled=false,releaseResolve,releaseReject,receipt;
|
||||
let resolve,reject;
|
||||
const acquired=new Promise((a,b)=>{resolve=a;reject=b;});
|
||||
const limit=Math.ceil(maxBytes*4/3)+65536;
|
||||
const timer=setTimeout(()=>fail(),30000);timer.unref();
|
||||
const lost=()=>{if(live&&!releasing){try{onLost();}catch{} }live=false;};
|
||||
function fail(){disabled=true;lost();clearTimeout(timer);if(!settled){settled=true;reject(refused());}releaseReject?.(refused());child.kill();}
|
||||
const abort=()=>fail();signal?.addEventListener('abort',abort,{once:true});
|
||||
child.on('error',fail);
|
||||
child.on('exit',()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);if(!releasing)fail();else{live=false;releaseReject?.(refused());}});
|
||||
child.stdin.on('error',fail);child.stderr.on('data',()=>fail());
|
||||
child.stdout.on('data',chunk=>{
|
||||
total+=chunk.length;if(total>limit){fail();return;}
|
||||
buffer=Buffer.concat([buffer,chunk]);
|
||||
let newline;
|
||||
while((newline=buffer.indexOf(10))!==-1){
|
||||
const line=buffer.subarray(0,newline);buffer=Buffer.from(buffer.subarray(newline+1));
|
||||
try{
|
||||
const msg=JSON.parse(line.toString('utf8'));
|
||||
if(!ready&&JSON.stringify(Object.keys(msg).sort())===JSON.stringify(['code','ok','version'])&&msg.version===VERSION&&msg.ok===false&&msg.code==='upload-lease-refused'){
|
||||
releasing=true;live=false;settled=true;clearTimeout(timer);reject(refused());continue;
|
||||
}
|
||||
if(ready){
|
||||
if(!releasing||JSON.stringify(Object.keys(msg).sort())!==JSON.stringify(['released','version'])||msg.version!==VERSION||msg.released!==true)throw refused();
|
||||
live=false;receipt=null;releaseResolve?.();releaseResolve=null;releaseReject=null;continue;
|
||||
}
|
||||
if(JSON.stringify(Object.keys(msg).sort())!==JSON.stringify(['files','handles','ok','version'])||msg.version!==VERSION||msg.ok!==true||!Array.isArray(msg.files)||msg.files.length!==paths.length||!Array.isArray(msg.handles)||msg.handles.length<2||msg.handles.length>4096||!msg.handles.every(v=>typeof v==='string'&&/^[1-9][0-9]{0,19}$/.test(v)&&BigInt(v)<2n**64n)||new Set(msg.handles).size!==msg.handles.length)throw refused();
|
||||
receipt=msg.handles;
|
||||
let bytesTotal=0;
|
||||
const files=msg.files.map((f,i)=>{
|
||||
if(JSON.stringify(Object.keys(f).sort())!==JSON.stringify(['bytes','fileId','path','sha256','size','volume'])||f.path!==paths[i]||!Number.isInteger(f.size)||f.size<0||!Number.isInteger(f.volume)||f.volume<0||f.volume>0xffffffff||typeof f.fileId!=='string'||!/^[a-f0-9]{16}$/.test(f.fileId)||typeof f.sha256!=='string'||!/^[a-f0-9]{64}$/.test(f.sha256)||typeof f.bytes!=='string')throw refused();
|
||||
const bytes=Buffer.from(f.bytes,'base64');bytesTotal+=bytes.length;
|
||||
if(bytesTotal>maxBytes||bytes.length!==f.size||bytes.toString('base64')!==f.bytes||crypto.createHash('sha256').update(bytes).digest('hex')!==f.sha256)throw refused();
|
||||
return Object.freeze({...f,bytes});
|
||||
});
|
||||
ready=true;settled=true;clearTimeout(timer);
|
||||
resolve({files:Object.freeze(files),isAlive:()=>live&&!releasing,release:()=>{
|
||||
if(releasing)return Promise.reject(refused());
|
||||
if(!live){
|
||||
releasing=true;
|
||||
return new Promise((a,b)=>{
|
||||
const cleanup=spawn(pythonPath,['-I','-B',helperPath],{windowsHide:true,stdio:['pipe','pipe','pipe'],env:{SystemRoot:process.env.SystemRoot,WINDIR:process.env.WINDIR},shell:false});
|
||||
const cleanupTimer=setTimeout(()=>{disabled=true;cleanup.kill();b(refused());},5000);cleanupTimer.unref();
|
||||
let output='';cleanup.stdout.on('data',c=>{output+=c.toString('utf8');if(output.length>1024)cleanup.kill();});cleanup.stderr.on('data',()=>cleanup.kill());cleanup.on('error',()=>{clearTimeout(cleanupTimer);disabled=true;b(refused());});cleanup.on('exit',code=>{clearTimeout(cleanupTimer);try{const msg=JSON.parse(output);if(code!==0||msg.version!==VERSION||msg.released!==true||Object.keys(msg).length!==2)throw refused();receipt=null;a();}catch{disabled=true;b(refused());}});
|
||||
cleanup.stdin.on('error',()=>b(refused()));cleanup.stdin.end(JSON.stringify({version:VERSION,parentPid:process.pid,closeHandles:receipt})+'\n');
|
||||
});
|
||||
}
|
||||
releasing=true;
|
||||
return new Promise((a,b)=>{const deadline=setTimeout(()=>fail(),5000);deadline.unref();releaseResolve=()=>{clearTimeout(deadline);a();};releaseReject=e=>{clearTimeout(deadline);b(e);};child.stdin.end('{"release":true}\n');});
|
||||
}});
|
||||
}catch{fail();}finally{line.fill(0);}
|
||||
}
|
||||
});
|
||||
child.stdin.write(JSON.stringify({version:VERSION,paths,maxBytes,parentPid:process.pid})+'\n');
|
||||
return acquired;
|
||||
}
|
||||
module.exports={acquireUploadLease};
|
||||
Reference in new issue
Block a user