Implement managed browser security, individual agents and observability

This commit is contained in:
Karl committed 2026-10-13 16:58:15 +02:00
1 parent ff482ebe23
commit ea448de2d4
172 files changed
+5404 -347

No files matched your search

+92 -11
View File
@@ -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)};}
};
}