Add policy-bound encrypted agent session restoration and audit traces
This commit is contained in:
1 parent
e8a654fb38
commit
b991ee9da6
27 files changed
+405
-24
No files matched your search
@@ -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(['vault:status','vault:list','vault:fill','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 APP_ACTIONS=Object.freeze(['sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','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,20 @@ 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 = { 'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'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];
|
||||
const keys = { 'sessions:save':['name','sessionIds','id','expectedRevision'],'sessions:list':[],'sessions:restore':['id','planId'],'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'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==='sessions:save'){
|
||||
clean.name=bounded(args.name,80,'session name');
|
||||
if(!Array.isArray(args.sessionIds)||args.sessionIds.length<1||args.sessionIds.length>32||new Set(args.sessionIds).size!==args.sessionIds.length)throw fail(400,'Invalid session selection');
|
||||
clean.sessionIds=args.sessionIds.map(id=>bounded(id,128,'session id'));
|
||||
for(const key of ['id','expectedRevision'])if(args[key]!==undefined)clean[key]=bounded(args[key],36,key);
|
||||
}
|
||||
if(action==='sessions:restore'){
|
||||
if((args.id!==undefined)===(args.planId!==undefined))throw fail(400,'Invalid session restore');
|
||||
const key=args.planId!==undefined?'planId':'id';clean[key]=bounded(args[key],36,key);
|
||||
}
|
||||
if(action.startsWith('sessions:'))for(const key of ['id','expectedRevision','planId'])if(clean[key]!==undefined&&!/^[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 session handle');
|
||||
if(action.startsWith('vault:')){
|
||||
if(args.sessionId!==undefined||action==='vault:fill')clean.sessionId=bounded(args.sessionId,128,'sessionId');
|
||||
if(action==='vault:fill'){clean.credentialRef=bounded(args.credentialRef,36,'credentialRef');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.credentialRef))throw fail(400,'Invalid credential reference');}
|
||||
@@ -59,6 +70,17 @@ function validate(body) {
|
||||
}
|
||||
// Never return adapter objects wholesale: session handles and sanitized text only.
|
||||
function project(action, result) {
|
||||
if(action.startsWith('sessions:')){
|
||||
const handle=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 session handle');return value;};
|
||||
if(action==='sessions:save')return {id:handle(result?.id),revision:handle(result?.revision),tabCount:sessionCount(result?.tabCount)};
|
||||
if(action==='sessions:restore'){
|
||||
if(result?.planId)return {planId:handle(result.planId),id:handle(result.id),tabCount:sessionCount(result.tabCount)};
|
||||
if(result?.completed!==true||!Array.isArray(result.sessionIds)||result.sessionIds.length<1||result.sessionIds.length>32||new Set(result.sessionIds).size!==result.sessionIds.length||!result.sessionIds.includes(result.activeSessionId))throw fail(502,'Invalid restored sessions');
|
||||
return {completed:true,sessionIds:result.sessionIds.map(handle),activeSessionId:handle(result.activeSessionId)};
|
||||
}
|
||||
if(!Array.isArray(result?.collections)||result.collections.length>16)throw fail(502,'Invalid session collections');
|
||||
return {revision:handle(result.revision),collections:result.collections.map(row=>{if(!Array.isArray(row.tabs)||row.tabs.length<1||row.tabs.length>32||!Number.isInteger(row.activeIndex)||row.activeIndex<0||row.activeIndex>=row.tabs.length)throw fail(502,'Invalid session collection');return {id:handle(row.id),name:bounded(row.name,80,'session name'),activeIndex:row.activeIndex,tabs:row.tabs.map(require('./agent-session-adapters.cjs').safeSessionTab)};})};
|
||||
}
|
||||
if(action==='vault:status'){
|
||||
if(!result||!['exists','locked','enabled','allowAutofill'].every(key=>typeof result[key]==='boolean'))throw fail(502,'Invalid vault response');
|
||||
return Object.fromEntries(['exists','locked','enabled','allowAutofill'].map(key=>[key,result[key]]));
|
||||
@@ -100,6 +122,7 @@ function project(action, result) {
|
||||
}
|
||||
return { completed: result?.completed !== false };
|
||||
}
|
||||
function sessionCount(value){if(!Number.isSafeInteger(value)||value<1||value>32)throw fail(502,'Invalid session count');return value;}
|
||||
|
||||
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');
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
const credential=/password|passwd|api.?key|access.?token|refresh.?token|client.?secret|authorization|credential|session.?id|-----BEGIN|\b(?:sk-|gh[pousr]_)[A-Za-z0-9_-]{16,}/i;
|
||||
function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f]/.test(value))throw Error('Invalid session metadata');let decoded=value;for(let i=0;i<4;i++){if(credential.test(decoded))throw Error('Sensitive session metadata');try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}return value;}
|
||||
function sensitiveURL(value){for(let i=0;i<5;i++){if(/[?&#](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|credential|session|sid|auth|samlresponse|samlrequest|assertion|ticket|id_token)=/i.test(value)||/\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|[?#]|$)/i.test(value))return true;try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{return true;}}return false;}
|
||||
function safeTab(row){if(!row||Object.keys(row).some(k=>!['url','title','group'].includes(k)))throw Error('Invalid session metadata');const title=safeText(row.title,200),raw=safeText(row.url,4096),url=new URL(raw);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||sensitiveURL(raw)||/\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|$)/i.test(decodeURIComponent(url.pathname)))throw Error('Unsafe session destination');return {url:url.href,title,...(row.group===undefined?{}:{group:safeText(row.group,80)})};}
|
||||
function createAgentSessionAdapters({getContext,getSessions,contextCurrent,getOwner,store,inspect,restore,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}){
|
||||
if([getContext,getSessions,contextCurrent,getOwner,inspect,restore].some(fn=>typeof fn!=='function')||!store)throw TypeError('Session callbacks required');
|
||||
const plans=new Map();
|
||||
const snapshot=(action,signal)=>{const c=getContext();if(signal?.aborted||!c?.ready||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||(c.policy.expiresAt&&Date.parse(c.policy.expiresAt)<=now()))throw Error('Agent session scope denied');const owner=getOwner(c);if(!owner||typeof owner.deviceId!=='string'||typeof owner.ownerId!=='string')throw Error('Session owner unavailable');return {...c,action,owner,actorBinding:JSON.stringify(c.actor??null),version:c.policyVersion??c.policy.version};};
|
||||
const current=(c,signal,allowActivation=false)=>{const n=snapshot(c.action,signal);if(n.policy!==c.policy||n.version!==c.version||n.actorBinding!==c.actorBinding||JSON.stringify(n.owner)!==JSON.stringify(c.owner)||(c.token&&!contextCurrent(allowActivation&&typeof c.token==='object'?{...c.token,foreground:false,activation:null}:c.token)))throw Error('Session authority changed');};
|
||||
const destination=(tab,c)=>{const url=new URL(tab.url),hosts=c.policy.agents.allowedHosts;if(typeof c.destinationAllowed!=='function'||c.destinationAllowed(tab.url,c.policy)!==true||!Array.isArray(hosts)||(hosts.length&&!hosts.some(h=>url.hostname===h||(h.startsWith('*.')&&url.hostname.endsWith(h.slice(1))))))throw Error('Session destination denied');};
|
||||
const classify=async(value,c,signal)=>{const text=JSON.stringify(value);if(text.length>200000)throw Error('Session metadata too large');const v=await traceOperation('inspect',()=>inspect(text,c));current(c,signal);if(v?.decision!=='allow'||v.coverage?.complete===false||/uninspectable|size-limit|inspection-incomplete|unavailable|failure|failed|timeout|unsupported|encrypted|parser/i.test(v.ruleId||''))throw Error('Session metadata inspection denied');};
|
||||
const load=async(c,id)=>{const result=await traceOperation('load',()=>store.load({...c.owner,id}));if(!result||typeof result.revision!=='string')throw Error('Session collection unavailable');const {revision,...collection}=result;return {revision,collection};};
|
||||
return {
|
||||
'sessions:save':async(args={}, {signal}={})=>{const c=snapshot('sessions:save',signal),name=safeText(args.name,80);if(!name.trim()||!Array.isArray(args.sessionIds)||args.sessionIds.length<1||args.sessionIds.length>32||new Set(args.sessionIds).size!==args.sessionIds.length)throw Error('Invalid session selection');const inventory=getSessions(),bindings=[];const tabs=args.sessionIds.map(id=>{const row=inventory.find(r=>r.id===id),tab=getContext(id);if(!row||!tab?.ready||!tab.token||!contextCurrent(tab.token))throw Error('Session selection changed');const value=safeTab({title:row.title,url:tab.webContents.getURL(),...(row.group===undefined?{}:{group:row.group})});destination(value,c);bindings.push({id,token:tab.token,url:value.url,title:row.title,group:row.group});return value;});const assertCurrent=()=>{current(c,signal);const rows=getSessions();for(const b of bindings){const tab=getContext(b.id);if(!contextCurrent(b.token)||tab?.webContents?.getURL()!==b.url||rows.find(r=>r.id===b.id)?.title!==b.title||rows.find(r=>r.id===b.id)?.group!==b.group)throw Error('Session selection changed');}};await classify({name,tabs},c,signal);assertCurrent();const existing=await store.list(c.owner);assertCurrent();if(args.expectedRevision!==undefined&&args.expectedRevision!==existing.revision)throw Error('Session collection changed');const activeIndex=Math.max(0,args.sessionIds.indexOf(c.sessionId));const result=await traceOperation('save',()=>store.save({...(args.id?{id:args.id}:{}),name,tabs,activeIndex},{...c.owner,expectedRevision:existing.revision,assertCurrent}));assertCurrent();return {id:result.id,revision:result.revision,tabCount:tabs.length};},
|
||||
'sessions:list':async(_args={}, {signal}={})=>{const c=snapshot('sessions:list',signal),result=await traceOperation('list',()=>store.list(c.owner));current(c,signal);if(!Array.isArray(result.collections)||result.collections.length>16)throw Error('Invalid session collections');const collections=result.collections.map(row=>({id:row.id,name:safeText(row.name,80),tabs:row.tabs.map(safeTab),activeIndex:row.activeIndex}));for(const row of collections)for(const tab of row.tabs)destination(tab,c);await classify(collections,c,signal);current(c,signal);if((await store.list(c.owner)).revision!==result.revision)throw Error('Session collection changed');current(c,signal);return {revision:result.revision,collections};},
|
||||
'sessions:restore':async(args={}, {signal}={})=>{const c=snapshot('sessions:restore',signal);if(!args.planId){const result=await load(c,args.id);current(c,signal);const collection={id:result.collection.id,name:safeText(result.collection.name,80),tabs:result.collection.tabs.map(safeTab),activeIndex:result.collection.activeIndex};if(!collection.tabs.length||collection.tabs.length>32)throw Error('Invalid session collection');for(const tab of collection.tabs)destination(tab,c);await classify(collection,c,signal);if((await store.list(c.owner)).revision!==result.revision)throw Error('Session collection changed');current(c,signal);for(const [id,p] of plans)if(p.expires<=now())plans.delete(id);while(plans.size>=32)plans.delete(plans.keys().next().value);const planId=randomUUID();plans.set(planId,{c,collection,revision:result.revision,expires:now()+60000});return {planId,id:collection.id,tabCount:collection.tabs.length};}const plan=plans.get(args.planId);plans.delete(args.planId);if(!plan||plan.expires<=now()||plan.c.policy!==c.policy||plan.c.actorBinding!==c.actorBinding||JSON.stringify(plan.c.owner)!==JSON.stringify(c.owner))throw Error('Session restore plan unavailable');current(plan.c,signal);const result=await load(c,plan.collection.id);if(result.revision!==plan.revision||JSON.stringify(result.collection)!==JSON.stringify(plan.collection))throw Error('Session collection changed');for(const tab of plan.collection.tabs)destination(tab,c);await classify(plan.collection,c,signal);if((await store.list(c.owner)).revision!==plan.revision)throw Error('Session collection changed');current(c,signal);return traceOperation('restore',()=>restore(plan.collection.tabs,{activeIndex:plan.collection.activeIndex,signal,assertCurrent:()=>{current(c,signal,true);for(const tab of plan.collection.tabs)destination(tab,c);},revision:plan.revision,owner:c.owner}));}
|
||||
};
|
||||
}
|
||||
module.exports={createAgentSessionAdapters,safeSessionTab:safeTab};
|
||||
@@ -16,6 +16,11 @@ const components=new Set(['browser','ipc','agent','management','navigation','net
|
||||
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
|
||||
names.add('browser.vault');
|
||||
for(const operation of ['vault:status','vault:list','vault:fill','vault.inventory.inspect','vault.fill.visual','vault.fill.native'])operations.add(operation);
|
||||
// Session collection stages use fixed identifiers; names and URLs are never attributes.
|
||||
names.add('browser.session');
|
||||
components.add('sessions');
|
||||
for(const operation of ['sessions:save','sessions:list','sessions:restore','sessions.inspect','sessions.load','sessions.list','sessions.save','sessions.restore','sessions.authorize','sessions.preflight','sessions.native','sessions.rollback'])operations.add(operation);
|
||||
for(const operation of ['audit.serialize','audit.encrypt','audit.write','audit.fsync','audit.commit'])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);
|
||||
@@ -64,6 +69,11 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
|
||||
async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
|
||||
activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
|
||||
}
|
||||
// Synchronous persistence must retain its native return/throw contract. Never
|
||||
// wrap fsync or DPAPI in a promise merely to add diagnostic timing.
|
||||
function spanSync(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
|
||||
activeSpans++;const started=performance.now();let status='ok';try{const result=storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
|
||||
}
|
||||
// Durable events may be delivered by a flusher started under another event's
|
||||
// async context. Adopt only data-property IDs; never inherit that unrelated
|
||||
// parent, invoke caller getters, or allow diagnostic failure to repeat work.
|
||||
@@ -84,6 +94,6 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
|
||||
}
|
||||
async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);}
|
||||
async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);}
|
||||
return {configure,span,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
|
||||
return {configure,span,spanSync,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
|
||||
}
|
||||
module.exports={createDiagnostics,attributes,endpointAllowed};
|
||||
+21
-6
@@ -192,7 +192,8 @@ function requireVault(){
|
||||
}
|
||||
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{}}
|
||||
const stage=(operation,fn)=>diagnostics.spanSync('browser.security',diagnosticContext({operation}),fn);
|
||||
try{const serialized=stage('audit.serialize',()=>JSON.stringify(auditQueue));const encrypted=stage('audit.encrypt',()=>safeStorage.encryptString(serialized));stage('audit.write',()=>{fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,encrypted);});stage('audit.fsync',()=>fs.fsyncSync(fd));stage('audit.commit',()=>{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)return;const delivery=require('./evidence-delivery.cjs');
|
||||
@@ -415,6 +416,7 @@ async function ensureAgentAPI(){
|
||||
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'agent-adapter'},policy));if(!payload)return;
|
||||
const actor=agentActorStorage.getStore();await auditAgent({action:'click',outcome:'completed',sessionId:metadata.sessionId,...(actor?.principalId?{principalId:actor.principalId,generation:actor.generation}:{}),protectedInteraction:payload});
|
||||
}});
|
||||
Object.assign(actions,agentSessionControls());
|
||||
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','history:go','vault:fill','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
|
||||
@@ -427,7 +429,7 @@ async function ensureAgentAPI(){
|
||||
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==='sessions'||['sessions:save','sessions:list','sessions:restore'].includes(action))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};
|
||||
@@ -627,21 +629,34 @@ function installSiteHooks(tab){
|
||||
});
|
||||
}
|
||||
function createNativeTab(input={}){return diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input));}
|
||||
async function createNativeTabImpl(input={}){
|
||||
async function createNativeTabImpl(input={},controls={}){
|
||||
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||''});tab.security.agentAuthority=input.agentAuthority||agentActorStorage.getStore()?.principalId&&{...agentActorStorage.getStore()};installSiteHooks(tab);
|
||||
controls.assertCurrent?.();const tab=tabs.create({group:input.group||''});
|
||||
try{
|
||||
controls.onCreated?.(tab);tab.security.agentAuthority=agentActorStorage.getStore()?.principalId&&{...agentActorStorage.getStore()};installSiteHooks(tab);
|
||||
await tab.view.webContents.loadURL('about:blank');
|
||||
controls.assertCurrent?.();
|
||||
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){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();
|
||||
controls.assertCurrent?.();tab.guarded=true;selectTab(tab.id);controls.onActivated?.(tab);controls.assertCurrent?.();
|
||||
if(input.url){if(tab.security.agentAuthority){await agentActorStorage.run(tab.security.agentAuthority,()=>navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal}));}else await navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal});}controls.assertCurrent?.();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(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();}
|
||||
let sessionSnapshotStore;
|
||||
function agentSessionControls(){
|
||||
requireEncryption();sessionSnapshotStore??=require('./session-snapshot.cjs').createSessionSnapshotStore({filePath:path.join(app.getPath('userData'),'managed-sessions.bin'),protect:bytes=>safeStorage.encryptString(bytes.toString('base64')),unprotect:bytes=>Buffer.from(safeStorage.decryptString(bytes),'base64')});
|
||||
const sessionContext=id=>{const context=agentContext(id),actor=agentActorStorage.getStore();return {...context,ready:context.ready&&actorGrantAllowed(actor,actor?.action),actor:context.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};};
|
||||
return require('./agent-session-adapters.cjs').createAgentSessionAdapters({getContext:sessionContext,getSessions:()=>tabs.all().map(tab=>({id:tab.id,title:tab.title,group:tab.group})),contextCurrent:currentTabContext,
|
||||
getOwner:context=>({deviceId:device.id,ownerId:JSON.stringify([policy.agents.identityMode,context.actor.agentId,context.actor.generation])}),store:sessionSnapshotStore,inspect:inspectManagedAgentText,
|
||||
traceOperation:(operation,fn)=>diagnosticSpan('browser.session',{operation:'sessions.'+operation},fn),
|
||||
restore:(collection,options)=>require('./session-restore.cjs').restoreNativeSession(collection,options,{registry:tabs,store:sessionSnapshotStore,
|
||||
create:async(input,controls)=>{const result=await diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input,controls));return result.activeTabId;},activate:id=>{selectTab(id);emit();},traceOperation:(operation,fn)=>diagnosticSpan('browser.session',{operation:'sessions.'+operation},fn)})
|
||||
});
|
||||
}
|
||||
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)});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
// Restoration creates managed native tabs; it never imports renderer pageState.
|
||||
async function restoreNativeSession(tabs,{activeIndex,signal,assertCurrent,revision,owner},{registry,store,create,activate,traceOperation=async(_name,fn)=>fn()}={}){
|
||||
if(!Array.isArray(tabs)||!tabs.length||tabs.length>32||!Number.isInteger(activeIndex)||activeIndex<0||activeIndex>=tabs.length||typeof assertCurrent!=='function')throw Error('Invalid managed session restore');
|
||||
const originals=registry.all().map(tab=>({id:tab.id,contents:tab.contents})),initialActive=registry.active()?.id,created=[];let expectedActive=initialActive;const epoch=()=>{const value=registry.context(registry.active()?.id,{foreground:true}).activation;if(!Number.isSafeInteger(value)||value<0)throw Error('Managed restore activation unavailable');return value;};let expectedEpoch=epoch();const ownActivation=id=>{const next=epoch(),required=expectedEpoch+(id===expectedActive?0:1);if(next!==required||registry.active()?.id!==id)throw Error('Managed restore activation changed');expectedActive=id;expectedEpoch=next;};
|
||||
const current=()=>{
|
||||
assertCurrent();if(signal?.aborted||store.list(owner).revision!==revision||registry.all().length!==originals.length+created.length||registry.active()?.id!==expectedActive||epoch()!==expectedEpoch)throw Error('Managed restore context changed');
|
||||
for(const row of [...originals,...created])if(registry.get(row.id).contents!==row.contents||row.contents.isDestroyed())throw Error('Managed restore tab changed');
|
||||
};
|
||||
await traceOperation('preflight',()=>{current();if(originals.length+tabs.length>32)throw Error('Native tab capacity reached');});
|
||||
try{
|
||||
for(const tab of tabs){current();const result=await traceOperation('native',()=>create({url:tab.url,...(tab.group?{group:tab.group}:{})},{signal,assertCurrent:current,onCreated:native=>{if(!native||originals.some(row=>row.id===native.id)||created.some(row=>row.id===native.id)||!registry.all().some(row=>row.id===native.id&&row.contents===native.contents))throw Error('Managed restore ownership mismatch');created.push({id:native.id,contents:native.contents});},onActivated:native=>ownActivation(native.id)}));current();if(result!==created.at(-1)?.id)throw Error('Managed restore tab mismatch');}
|
||||
current();activate(created[activeIndex].id);ownActivation(created[activeIndex].id);current();return {completed:true,sessionIds:created.map(row=>row.id),activeSessionId:expectedActive};
|
||||
}catch(error){
|
||||
// Closing owned tabs cannot undo network requests already sent before failure.
|
||||
let rollbackFailed=false;await traceOperation('rollback',()=>{for(const row of [...created].reverse()){try{if(registry.all().some(tab=>tab.id===row.id&&tab.contents===row.contents))registry.remove(row.id);}catch{rollbackFailed=true;}}try{if(initialActive&®istry.all().some(tab=>tab.id===initialActive&&!tab.contents.isDestroyed()))activate(initialActive);}catch{rollbackFailed=true;}});if(rollbackFailed)throw Error('Managed session rollback incomplete',{cause:error});throw error;
|
||||
}
|
||||
}
|
||||
module.exports={restoreNativeSession};
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
const fs=require('node:fs'),path=require('node:path'),{randomUUID}=require('node:crypto');
|
||||
const MAX_FILE=1048576, UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const fail=()=>{throw Error('Managed session collection unavailable');};
|
||||
const secret=s=>/-----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(s);
|
||||
function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f\u007f]/.test(value))fail();let decoded=value;for(let i=0;i<5;i++){if(secret(decoded))fail();try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}if(secret(decoded))fail();return value;}
|
||||
// Preserve benign query/fragment semantics; identifiable authentication material
|
||||
// and authentication routes are never reusable collection destinations.
|
||||
function unsafeURL(url){let decoded=url.href;for(let i=0;i<5;i++){if(/[?&#;](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|auth|credential|session|sid|samlresponse|samlrequest|assertion|ticket|id[_-]?token)=/i.test(decoded))return true;try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}let pathname=url.pathname;for(let i=0;i<5;i++){try{const next=decodeURIComponent(pathname);if(next===pathname)break;pathname=next;}catch{break;}}return /\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|$)/i.test(pathname);}
|
||||
function sanitizeSessionCollection(input){
|
||||
if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['id','name','tabs','activeIndex'].includes(k)))fail();
|
||||
if(input.id!==undefined&&!UUID.test(input.id))fail();
|
||||
const name=safeText(input.name,100);if(!name.trim()||!Array.isArray(input.tabs)||input.tabs.length<1||input.tabs.length>32||!Number.isInteger(input.activeIndex)||input.activeIndex<0||input.activeIndex>=input.tabs.length)fail();
|
||||
const tabs=input.tabs.map(row=>{if(!row||typeof row!=='object'||Array.isArray(row)||Object.keys(row).some(k=>!['url','title','group'].includes(k)))fail();const value=safeText(row.url,4096);let url;try{url=new URL(value);}catch{fail();}if(!['http:','https:'].includes(url.protocol)||url.username||url.password||unsafeURL(url))fail();return {url:url.href,title:safeText(row.title,200),...(row.group===undefined?{}:{group:safeText(row.group,100)})};});
|
||||
const result={...(input.id?{id:input.id}:{}),name,tabs,activeIndex:input.activeIndex};if(Buffer.byteLength(JSON.stringify(result))>196608)fail();return result;
|
||||
}
|
||||
function createSessionSnapshotStore({filePath,protect,unprotect}={}){
|
||||
if(typeof filePath!=='string'||!path.isAbsolute(filePath)||typeof protect!=='function'||typeof unprotect!=='function')fail();
|
||||
const binding=({deviceId,ownerId}={})=>{if(typeof deviceId!=='string'||!UUID.test(deviceId)||typeof ownerId!=='string'||ownerId.length<1||ownerId.length>512)fail();return {deviceId,ownerId};};
|
||||
function read(){if(!fs.existsSync(filePath))return {format:1,revision:randomUUID(),collections:[]};const stat=fs.lstatSync(filePath);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||stat.size<1||stat.size>MAX_FILE)fail();let encrypted,plain,fd;try{fd=fs.openSync(filePath,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW||0));const current=fs.fstatSync(fd);if(current.ino!==stat.ino||current.dev!==stat.dev||current.nlink!==1||current.size>MAX_FILE)fail();encrypted=fs.readFileSync(fd);if(encrypted.length>MAX_FILE)fail();plain=unprotect(encrypted);if(!Buffer.isBuffer(plain)||plain.length>MAX_FILE)fail();const data=JSON.parse(plain.toString('utf8'));if(data.format!==1||!UUID.test(data.revision)||!Array.isArray(data.collections)||data.collections.length>16)fail();const ids=new Set();data.collections=data.collections.map(row=>{binding(row);const collection=sanitizeSessionCollection(row.collection);if(!collection.id||ids.has(collection.id))fail();ids.add(collection.id);return {deviceId:row.deviceId,ownerId:row.ownerId,collection};});return data;}catch{fail();}finally{if(fd!==undefined)fs.closeSync(fd);plain?.fill(0);encrypted?.fill(0);}}
|
||||
function write(data,options){let plain,encrypted,temp,fd;try{plain=Buffer.from(JSON.stringify(data));if(plain.length>MAX_FILE)fail();encrypted=protect(plain);if(!Buffer.isBuffer(encrypted)||encrypted.length<1||encrypted.length>MAX_FILE)fail();fs.mkdirSync(path.dirname(filePath),{recursive:true});if(fs.existsSync(filePath)){const stat=fs.lstatSync(filePath);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1)fail();}temp=filePath+'.'+randomUUID()+'.tmp';fd=fs.openSync(temp,'wx',0o600);fs.writeFileSync(fd,encrypted);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;if(state().revision!==options.expectedRevision)fail();const checked=options.assertCurrent?.();if(checked&&typeof checked.then==='function')fail();if(state().revision!==options.expectedRevision)fail();fs.renameSync(temp,filePath);temp=null;}finally{if(fd!==undefined)fs.closeSync(fd);if(temp)try{fs.unlinkSync(temp);}catch{}plain?.fill(0);encrypted?.fill(0);}}
|
||||
let emptyRevision=randomUUID();
|
||||
const state=()=>fs.existsSync(filePath)?read():{format:1,revision:emptyRevision,collections:[]};
|
||||
const matches=(row,b)=>row.deviceId===b.deviceId&&row.ownerId===b.ownerId;
|
||||
return {
|
||||
list(options){const b=binding(options),data=state();return {revision:data.revision,collections:data.collections.filter(row=>matches(row,b)).map(row=>structuredClone(row.collection))};},
|
||||
load(options){const b=binding(options);if(!UUID.test(options.id))fail();const data=state(),row=data.collections.find(row=>matches(row,b)&&row.collection.id===options.id);if(!row)fail();return {revision:data.revision,...structuredClone(row.collection)};},
|
||||
save(input,options){const b=binding(options),data=state();if(options.expectedRevision!==data.revision)fail();const collection=sanitizeSessionCollection(input);const existing=collection.id&&data.collections.find(row=>row.collection.id===collection.id);if(collection.id&&(!existing||!matches(existing,b)))fail();if(!existing&&data.collections.length>=16)fail();collection.id??=randomUUID();if(existing)existing.collection=collection;else data.collections.push({...b,collection});data.revision=randomUUID();write(data,options);emptyRevision=data.revision;return {id:collection.id,revision:data.revision};},
|
||||
remove(options){const b=binding(options),data=state();if(options.expectedRevision!==data.revision||!UUID.test(options.id))fail();const index=data.collections.findIndex(row=>matches(row,b)&&row.collection.id===options.id);if(index<0)fail();data.collections.splice(index,1);data.revision=randomUUID();write(data,options);return {revision:data.revision};}
|
||||
};
|
||||
}
|
||||
module.exports={sanitizeSessionCollection,createSessionSnapshotStore};
|
||||
Reference in new issue
Block a user