Files
safetynet/apps/browser/agent-adapters.cjs
T
2026-10-13 10:34:48 +02:00

113 lines
10 KiB
JavaScript

'use strict';
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;
};
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) {
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 || '');
};
if (mode === 'read') {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const parts = []; let length = 0, visited = 0, node;
while ((node = walker.nextNode()) && visited++ < 20000 && length < 10000) {
const element = node.parentElement;
if (!element || element.closest('input,textarea,select,option,[contenteditable],script,style,template,noscript') || !visible(element) || sensitive(element)) continue;
const value = (node.textContent || '').replace(/\s+/g, ' ').trim();
if (value) { const piece = value.slice(0, 10000 - length); parts.push(piece); length += piece.length + 1; }
}
return { text: parts.join('\n').slice(0,10000) };
}
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 (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};
}
if (mode === 'type') {
if (e.readOnly || !(e.tagName === 'TEXTAREA' || (e.tagName === 'INPUT' && /^(text|search|email|url|tel)$/i.test(e.type)))) throw Error('Target cannot receive text');
const prototype = e.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(prototype,'value').set.call(e,text);
e.dispatchEvent(new Event('input',{bubbles:true})); e.dispatchEvent(new Event('change',{bubbles:true}));
return {completed:true};
}
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)})`;
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={}} = {}) {
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');
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 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');
return {...c,url,action,version:c.policyVersion ?? c.policy.version};
};
const check = (initial,args,signal,expectedUrl=initial.url) => {
const current = snapshot({...args,sessionId:initial.sessionId},signal,initial.action);
if (!contextCurrent(initial.token) || current.version !== initial.version || current.webContents !== initial.webContents || current.url !== expectedUrl) throw Error('Managed session changed during action');
return current;
};
const classify = async (text,initial,args,signal,allowSensitive=false) => {
if (typeof text!=='string' || text.length>10000 || secret(text)) throw Error('Sensitive content cannot be returned');
const verdict = await inspect(text,initial);check(initial,args,signal);
if (!verdict || !['allow','block'].includes(verdict.decision)) throw Error('Inspection unavailable');
const reserved=/^(?:private-key|aws-access-key|api-secret|uninspectable|size-limit|inspection-incomplete|yara-.*)$/;
const knownCorporateRule=verdict.ruleId==='content-blocklist' || initial.policy.dlp?.rules?.some(rule=>rule.id===verdict.ruleId && ['literal','sha256','fragment-sha256'].includes(rule.kind));
const override=allowSensitive && knownCorporateRule && !reserved.test(verdict.ruleId || '') && !verdict.malware && verdict.engine!=='yara' && verdict.coverage?.complete!==false;
if (verdict.decision==='block' && !override) { await audit({action:'agent.read',outcome:'blocked'});throw Error('Company policy blocks content access'); }
return text;
};
const dom = mode => async (args,{signal}={}) => {
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');
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 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;
const current=()=>{const c=appSnapshot(action);if(signal?.aborted||c.policy!==initial.policy||(c.policyVersion??c.policy.version)!==version)throw Error('Managed session changed during action');};
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==='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');}
}
return result;
}]));
return {
...appAdapters,
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'),
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)};}
};
}
module.exports={createAgentAdapters};