Add scoped native history and window agent controls

This commit is contained in:
Karl committed 2026-10-13 18:48:40 +02:00
1 parent adf0940e5f
commit 61fcd8336f
27 files changed
+503 -25

No files matched your search

+55 -4
View File
@@ -1,4 +1,5 @@
'use strict';
const {randomUUID}=require('node:crypto');
function domAction(mode, selector, text, expectedUrl) {
if (location.href !== expectedUrl) throw Error('Document changed before action');
const visible = e => {
@@ -106,15 +107,15 @@ const hostMatches = (hostname, pattern) => {
const name=hostname.toLowerCase().replace(/\.$/,''),p=String(pattern).toLowerCase();
return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name!==p.slice(2) : name===p;
};
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,onInteraction,appControls={}} = {}) {
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,getHistory,goHistory,ai,inspect,audit,onInteraction,appControls={}} = {}) {
if ([getContext,getSessions,contextCurrent,navigate,ai,inspect,audit].some(fn=>typeof fn!=='function')) throw TypeError('All adapter callbacks are required');
const snapshot = (args,signal,action) => {
const c = getContext(args.sessionId);
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','scroll','select','check','back','forward','reload'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable');
if (['click','type','scroll','select','check','back','forward','reload','history:go'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable');
const url = c.webContents.getURL();
const changesDestination=['navigate','back','forward'].includes(action);
const changesDestination=['navigate','back','forward','history:go'].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');
@@ -152,7 +153,7 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.url,outcome:'completed',sensitiveField:false,...(target?{target}:{})});check(initial,args,signal);}
return {completed:result?.completed===true};
};
const appActions=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
const appActions=['window:state','window:control','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}={})=>{
@@ -166,6 +167,55 @@ await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.
check({...refreshed,action,version:initial.version,url},args,signal,url);
return {completed:true};
}]));
const historySnapshots=new Map();
const historyInventory=initial=>{
if(typeof getHistory!=='function')throw Error('Managed history unavailable');
const data=getHistory(initial);
if(!data||!Array.isArray(data.entries)||data.entries.length>100||!Number.isInteger(data.activeIndex)||data.activeIndex<0||data.activeIndex>=data.entries.length)throw Error('Invalid managed history');
return {activeIndex:data.activeIndex,entries:data.entries.map(row=>{
if(typeof row.url!=='string'||row.url.length>4096||typeof row.title!=='string'||row.title.length>200)throw Error('Invalid managed history');
return {url:row.url,title:row.title};
}),...(data.nativeBinding===undefined?{}:/^[a-f0-9]{64}$/.test(data.nativeBinding)&&typeof data.nativeBinding==='string'?{nativeBinding:data.nativeBinding}:(()=>{throw Error('Invalid managed history');})())};
};
const historyCredential=text=>{let value=text;for(let i=0;i<3;i++){if(secret(value))return true;try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{break;}}return secret(value);};
const historyAdaptersExtra={
'history:list':async(args={}, {signal}={})=>{
const initial=snapshot(args,signal,'history:list'),data=historyInventory(initial),binding=JSON.stringify(data),actorBinding=JSON.stringify(initial.actor??null);
const current=()=>{check(initial,args,signal);if(getContext(initial.sessionId)?.policy!==initial.policy||JSON.stringify(getContext(initial.sessionId)?.actor??null)!==actorBinding||JSON.stringify(historyInventory(initial))!==binding)throw Error('Managed history changed during action');};
const entries=[];
for(let index=0;index<data.entries.length;index++){
const row=data.entries[index];let url;try{url=new URL(row.url);}catch{continue;}
if(!['http:','https:'].includes(url.protocol)||initial.destinationAllowed(url.href,initial.policy)!==true||(initial.policy.agents.allowedHosts.length&&!initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))continue;
let decodedUrl=row.url;for(let pass=0;pass<3;pass++){try{const next=decodeURIComponent(decodedUrl);if(next===decodedUrl)break;decodedUrl=next;}catch{break;}}
const credential=!!(url.username||url.password)||historyCredential(row.title+'\n'+row.url)||/[?&#](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|session|sid)=/i.test(decodedUrl);
let allowed=false;
if(!credential){
const verdict=await inspect(row.title+'\n'+row.url,{...initial,action:'history:list'});current();
if(!verdict||!['allow','block'].includes(verdict.decision)||verdict.coverage?.complete===false||/uninspectable|size-limit|inspection-incomplete|unavailable|failure|failed|timeout|unsupported|encrypted|parser/i.test(verdict.ruleId||''))throw Error('Inspection unavailable');
allowed=verdict.decision==='allow';
}
entries.push({navigable:!credential,entryId:randomUUID(),title:allowed?row.title.slice(0,160):'Protected page',...(allowed?{url:row.url}:{}),active:index===data.activeIndex,index});
}
current();
for(const [id,item] of historySnapshots)if(item.expires<=Date.now())historySnapshots.delete(id);
while(historySnapshots.size>=32)historySnapshots.delete(historySnapshots.keys().next().value);
const snapshotId=randomUUID();historySnapshots.set(snapshotId,{initial,binding,actorBinding,entries,expires:Date.now()+60000});
return {snapshotId,entries:entries.map(({index,navigable,...row})=>row)};
},
'history:go':async(args={}, {signal}={})=>{
const initial=snapshot(args,signal,'history:go'),saved=historySnapshots.get(args.snapshotId);
if(!saved||saved.expires<=Date.now()||saved.initial.sessionId!==initial.sessionId||saved.actorBinding!==JSON.stringify(initial.actor??null)||saved.initial.webContents!==initial.webContents||saved.initial.policy!==initial.policy||saved.initial.version!==initial.version||!contextCurrent(saved.initial.token)||saved.initial.url!==initial.url||JSON.stringify(historyInventory(initial))!==saved.binding)throw Error('Managed history changed during action');
const entry=saved.entries.find(row=>row.entryId===args.entryId);
if(!entry||entry.navigable!==true||typeof goHistory!=='function')throw Error('Unknown managed history entry');
const target=JSON.parse(saved.binding).entries[entry.index].url,url=new URL(target);
if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(target,initial.policy)!==true||(initial.policy.agents.allowedHosts.length&&!initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Destination blocked');
check(initial,args,signal);historySnapshots.delete(args.snapshotId);
const result=await goHistory(entry.index,initial,{signal,historyBinding:saved.binding}),refreshed=result?.context;
if(result?.completed!==true||!refreshed||refreshed.sessionId!==initial.sessionId||refreshed.webContents!==initial.webContents||refreshed.policy!==initial.policy||(refreshed.policyVersion??refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');
check({...refreshed,action:'history:go',version:initial.version,url:target},args,signal,target);
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;
@@ -186,6 +236,7 @@ await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.
return {
...appAdapters,
...historyAdapters,
...historyAdaptersExtra,
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');
+22 -2
View File
@@ -3,7 +3,7 @@ const http = require('node:http');
const crypto = require('node:crypto');
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','library:organize-plan','library:organize-apply']);
const APP_ACTIONS=Object.freeze(['history:list','history:go','window:state','window:control','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) => {
@@ -16,9 +16,14 @@ 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'],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];
const keys = { 'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],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.startsWith('history:')){
clean.sessionId=bounded(args.sessionId,128,'sessionId');
if(action==='history:go')for(const key of ['snapshotId','entryId']){clean[key]=bounded(args[key],36,key);if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean[key]))throw fail(400,'Invalid history handle');}
}
if(action==='window:control'){if(!['minimize','maximize','restore','focus'].includes(args.command))throw fail(400,'Invalid window command');clean.command=args.command;}
if (action !== 'sessions' && args.sessionId !== undefined) clean.sessionId = bounded(args.sessionId, 128, 'sessionId');
if (action === 'navigate') {
let url; try { url = new URL(bounded(args.url, 4096, 'url')); } catch { throw fail(400, 'Invalid URL'); }
@@ -50,6 +55,21 @@ function validate(body) {
}
// Never return adapter objects wholesale: session handles and sanitized text only.
function project(action, result) {
if(action==='history:list'){
const uuid=value=>{if(typeof value!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value))throw fail(502,'Invalid history response');return value;};
if(!Array.isArray(result?.entries)||result.entries.length>100)throw fail(502,'Invalid history response');
return {snapshotId:uuid(result.snapshotId),entries:result.entries.map(row=>{
if(typeof row.title!=='string'||row.title.length>200||typeof row.active!=='boolean')throw fail(502,'Invalid history response');
const item={entryId:uuid(row.entryId),title:row.title,active:row.active};
if(row.url!==undefined){let u;try{u=new URL(row.url);}catch{throw fail(502,'Invalid history response');}if(typeof row.url!=='string'||row.url.length>4096||!['http:','https:'].includes(u.protocol)||u.username||u.password)throw fail(502,'Invalid history response');item.url=u.href;}
return item;
})};
}
if(action==='window:state'){
if(!result||!['width','height'].every(key=>Number.isSafeInteger(result[key])&&result[key]>=1&&result[key]<=32768)||!['visible','focused','minimized','maximized','fullScreen'].every(key=>typeof result[key]==='boolean'))throw fail(502,'Invalid window response');
return Object.fromEntries(['width','height','visible','focused','minimized','maximized','fullScreen'].map(key=>[key,result[key]]));
}
if(action==='history:go'||action==='window:control')return {completed:result?.completed===true};
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};}
+2 -1
View File
@@ -10,9 +10,10 @@ 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,TraceFlags}=require('@opentelemetry/api');
const names=new Set(['browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']);
const names=new Set(['browser.window','browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']);
const operations=new Set(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']);
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
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);
+7 -4
View File
@@ -1,14 +1,17 @@
'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');
async function performHistory({contents,action,allowed,current,signal,index,getSnapshot,historyBinding,timeoutMs=10000}={}) {
if(!contents||!['back','forward','reload','index'].includes(action)||typeof allowed!=='function'||typeof current!=='function'||!Number.isFinite(timeoutMs)||timeoutMs<1||timeoutMs>60000||action==='index'&&(!Number.isSafeInteger(index)||index<0||typeof getSnapshot!=='function'||typeof historyBinding!=='string'))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;
const target=()=>action==='reload'?contents.getURL():contents.navigationHistory.getEntryAtIndex(action==='index'?index: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');
if(action==='index'&&index===contents.navigationHistory.getActiveIndex()){
if(JSON.stringify(getSnapshot())!==historyBinding||contents.getURL()!==url)throw Error('History snapshot changed');return {completed:true,url};
}
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);};
@@ -21,7 +24,7 @@ async function performHistory({contents,action,allowed,current,signal,timeoutMs=
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);}
try{if(action==='index'&&JSON.stringify(getSnapshot())!==historyBinding)throw Error('History snapshot changed');if(target()!==url)throw Error('History destination changed');dispatched=true;if(action==='reload')contents.reload();else contents.navigationHistory.goToIndex(action==='index'?index:contents.navigationHistory.getActiveIndex()+(action==='back'?-1:1));}catch(error){fail(error);}
});
}
module.exports={performHistory};
+18
View File
@@ -0,0 +1,18 @@
'use strict';
const {createHash}=require('node:crypto');
// Chromium pageState can contain form values. Keep only a private comparison
// checksum; never expose serialized pageState or the checksum through the API.
function readHistorySnapshot(contents){
if(!contents||contents.isDestroyed())throw Error('Managed history unavailable');
const history=contents.navigationHistory,rows=history.getAllEntries(),activeIndex=history.getActiveIndex();
if(!Array.isArray(rows)||rows.length<1||rows.length>100||!Number.isInteger(activeIndex)||activeIndex<0||activeIndex>=rows.length)throw Error('History size limit');
const hash=createHash('sha256');let total=0;
const entries=rows.map(row=>{
if(typeof row.url!=='string'||row.url.length>4096||typeof row.title!=='string'||row.title.length>200||row.pageState!==undefined&&typeof row.pageState!=='string')throw Error('History size limit');
const state=row.pageState??null;if(state!==null){total+=state.length;if(state.length>1048576||total>8388608)throw Error('History size limit');}
const bytes=Buffer.from(JSON.stringify([row.url,row.title,state]));try{hash.update(bytes);}finally{bytes.fill(0);}
return {url:row.url,title:row.title};
});
return {activeIndex,entries,nativeBinding:hash.digest('hex')};
}
module.exports={readHistorySnapshot};
+19 -3
View File
@@ -329,7 +329,19 @@ async function agentHistory(action,captured,{signal}={}){
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 nativeHistorySnapshot(contents){
return require('./history-snapshot.cjs').readHistorySnapshot(contents);
}
function getAgentHistory(captured){if(!currentTabContext(captured.token)||captured.webContents.isDestroyed())throw Error('Agent context changed');return nativeHistorySnapshot(captured.webContents);}
async function goAgentHistory(index,captured,{signal,historyBinding}={}){
if(!currentTabContext(captured.token)||captured.foreground!==true)throw Error('Agent context changed');
const tab=tabs.get(captured.sessionId),contents=tab.contents,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).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:'history:go'},()=>require('./history-navigation.cjs').performHistory({contents,action:'index',index,getSnapshot:()=>nativeHistorySnapshot(contents),historyBinding,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(),actor=agentActorStorage.getStore();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,...(actor?.principalId?{actor:Object.freeze({agentId:actor.principalId,generation:actor.generation})}:{}),destinationAllowed:url=>actorDestinationAllowed(url)};}
function managedAI(...args){return diagnosticSpan('browser.ai',{},()=>managedAIImpl(...args));}
async function managedAIImpl(prompt){
requireFeatureSection('ai');
@@ -387,7 +399,7 @@ 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().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,
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,getHistory:getAgentHistory,goHistory:goAgentHistory,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;
@@ -401,7 +413,7 @@ async function ensureAgentAPI(){
}});
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};}
if(context.principalId&&['navigate','back','forward','reload','history:go','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:[],
@@ -627,11 +639,15 @@ function updateManagedAppearance(input){requireManagedSection('appearance');appe
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(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 actorIdentity=()=>{const actor=agentActorStorage.getStore();return actor?.principalId?`${actor.principalId}:${actor.generation}`:'legacy';};
const windowControls=require('./window-controls.cjs').createWindowControls({getWindow:()=>win,getContext:()=>({ready:ready&&featureAllowed(managedSettings(),'agents'),policy,policyVersion:policy?.version,deviceId:device?.id,actor:actorIdentity()}),isCurrent:context=>ready&&policy===context.policy&&device?.id===context.deviceId&&actorIdentity()===context.actor,authorize:action=>actorGrantAllowed(agentActorStorage.getStore(),action),traceOperation:(operation,fn)=>diagnosticSpan('browser.window',{operation},fn)});
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,{signal});
};
return {
'window:state':checked((args,options)=>windowControls.state(args,options)),
'window:control':checked((args,options)=>windowControls.control(args,options)),
'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)),
+51
View File
@@ -0,0 +1,51 @@
'use strict';
// Only the managed application window is exposed. Screen positions, titles,
// arbitrary native handles and executable arguments never cross this boundary.
const commands = Object.freeze(['minimize','maximize','restore','focus']);
function createWindowControls({getWindow,getContext,isCurrent,authorize=()=>true,traceOperation=(_name,fn)=>fn()}={}) {
if([getWindow,getContext,isCurrent,authorize,traceOperation].some(fn=>typeof fn!=='function'))throw TypeError('Window callbacks are required');
const snapshot=(action,signal)=>{
const context=getContext(),window=getWindow(),policy=context?.policy;
if(signal?.aborted||context?.ready!==true||policy?.agents?.enabled!==true||!Array.isArray(policy.agents.scopes)||!policy.agents.scopes.includes(action))throw Error('Agent window scope denied');
if(policy.expiresAt&&(!Number.isFinite(Date.parse(policy.expiresAt))||Date.parse(policy.expiresAt)<=Date.now()))throw Error('Managed policy expired');
if(!window||window.isDestroyed()||!window.webContents||window.webContents.isDestroyed())throw Error('Managed window unavailable');
return {context,window,contents:window.webContents,policy,version:context.policyVersion??policy.version};
};
const check=(initial,action,signal)=>{
const latest=snapshot(action,signal);
if(latest.window!==initial.window||latest.contents!==initial.contents||latest.policy!==initial.policy||latest.version!==initial.version||isCurrent(initial.context)!==true)throw Error('Managed window changed during action');
};
const approved=async(initial,action,signal)=>{
check(initial,action,signal);
if(await authorize(action,initial.context)!==true)throw Error('Agent window authorization denied');
check(initial,action,signal);
};
const project=window=>{
const bounds=window.getBounds();
if(!bounds||!['width','height'].every(key=>Number.isSafeInteger(bounds[key])&&bounds[key]>=0&&bounds[key]<=65535))throw Error('Invalid managed window dimensions');
const result={width:bounds.width,height:bounds.height};
for(const [key,method] of [['visible','isVisible'],['focused','isFocused'],['minimized','isMinimized'],['maximized','isMaximized'],['fullScreen','isFullScreen']]){
const value=window[method]();if(typeof value!=='boolean')throw Error('Invalid managed window state');result[key]=value;
}
return result;
};
const run=async(action,args,options={})=>{
if(!args||Object.getPrototypeOf(args)!==Object.prototype||Object.keys(args).some(key=>action==='window:control'?key!=='command':true)||action==='window:control'&&!commands.includes(args.command))throw Error('Invalid window action');
const {signal}=options,initial=snapshot(action,signal);
await approved(initial,action,signal);
return traceOperation(action,async()=>{
await approved(initial,action,signal);
if(action==='window:control'){
// Electron operations are synchronous. Do not retry an effect after a
// subsequent cancellation, policy change or diagnostic delivery error.
try{initial.window[args.command]();}catch{throw Error('Managed window operation failed');}
await Promise.resolve();
}
await approved(initial,action,signal);
const state=project(initial.window);check(initial,action,signal);
return action==='window:control'?{completed:true,state}:state;
});
};
return {state:(args={},options)=>run('window:state',args,options),control:(args,options)=>run('window:control',args,options)};
}
module.exports={createWindowControls};