2026-10-13 19:14:32 +02:00
|
|
|
'use strict';
|
|
|
|
|
const {randomUUID}=require('node:crypto');
|
2026-10-13 21:00:41 +02:00
|
|
|
const {fillScript,prepareFillScript}=require('./password-fill.cjs');
|
2026-10-13 19:14:32 +02:00
|
|
|
const refused=()=>{throw Error('Managed vault operation refused');};
|
|
|
|
|
const idPattern=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
|
const hostMatch=(name,pattern)=>pattern.startsWith('*.')?name.endsWith(pattern.slice(1))&&name!==pattern.slice(2):name===pattern;
|
2026-10-13 21:00:41 +02:00
|
|
|
function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisual,inspect,audit,beforeFill=async()=>{},afterFill=()=>{},prepareSubmission,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}) {
|
2026-10-13 19:14:32 +02:00
|
|
|
if([getContext,contextCurrent,getVault,checkVisual,inspect,audit,traceOperation,now].some(fn=>typeof fn!=='function'))throw TypeError('Vault boundary callbacks required');
|
|
|
|
|
const references=new Map();
|
|
|
|
|
const allowed=(origin,c)=>{
|
|
|
|
|
let u;try{u=new URL(origin);}catch{return false;}
|
|
|
|
|
const hosts=c.policy.agents.allowedHosts;
|
|
|
|
|
return u.protocol==='https:'&&!u.username&&!u.password&&u.origin===origin&&Array.isArray(hosts)&&(!hosts.length||hosts.some(p=>typeof p==='string'&&hostMatch(u.hostname.toLowerCase(),p.toLowerCase())))&&c.destinationAllowed(origin,c.policy)===true;
|
|
|
|
|
};
|
|
|
|
|
function snapshot(args,signal,action){
|
2026-10-13 21:00:41 +02:00
|
|
|
const fields=['vault:fill','vault:prepare-submit'].includes(action)?['sessionId','credentialRef']:['sessionId'];
|
2026-10-13 19:14:32 +02:00
|
|
|
if(!args||typeof args!=='object'||Array.isArray(args)||Object.keys(args).some(key=>!fields.includes(key))||(args.sessionId!==undefined&&!idPattern.test(args.sessionId)))refused();
|
|
|
|
|
const c=getContext(args.sessionId);
|
|
|
|
|
if(signal?.aborted||!c?.ready||!c.token||(!c.actor||typeof c.actor.agentId!=='string'||!c.actor.agentId||!Number.isSafeInteger(c.actor.generation))||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||!c.policy.passwordManager?.enabled||!Number.isFinite(Date.parse(c.policy.expiresAt))||Date.parse(c.policy.expiresAt)<=now()||typeof c.destinationAllowed!=='function')refused();
|
|
|
|
|
return {...c,action};
|
|
|
|
|
}
|
|
|
|
|
function check(c,args,signal){
|
|
|
|
|
const next=snapshot(args,signal,c.action);
|
|
|
|
|
if(next.policy!==c.policy||next.actor.agentId!==c.actor.agentId||next.actor.generation!==c.actor.generation||!contextCurrent(c.token)||next.sessionId!==c.sessionId||next.webContents!==c.webContents)refused();
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
function unlocked(c){const vault=getVault();if(!vault||typeof vault.accessGeneration!=='function'||vault.status().locked)refused();return vault;}
|
|
|
|
|
function currentVault(c,args,signal,vault){check(c,args,signal);if(getVault()!==vault||vault.status().locked)refused();}
|
|
|
|
|
const status=async(args={}, {signal}={})=>{
|
|
|
|
|
const c=snapshot(args,signal,'vault:status'),s=getVault().status();check(c,args,signal);
|
|
|
|
|
return {exists:s.exists===true,locked:s.locked!==false,enabled:true,allowAutofill:c.policy.passwordManager.allowAutofill===true};
|
|
|
|
|
};
|
|
|
|
|
const list=async(args={}, {signal}={})=>{
|
|
|
|
|
const c=snapshot(args,signal,'vault:list'),vault=unlocked(c),generation=vault.accessGeneration(),rows=vault.list();
|
|
|
|
|
if(!Array.isArray(rows)||rows.length>1000)refused();
|
|
|
|
|
const safe=rows.filter(row=>row&&idPattern.test(row.id)&&allowed(row.origin,c)).map(row=>({id:row.id,origin:row.origin}));
|
|
|
|
|
const verdict=await traceOperation('vault.inventory.inspect',()=>inspect(safe.map(row=>row.origin).join('\n'),c));
|
|
|
|
|
currentVault(c,args,signal,vault);
|
|
|
|
|
if(vault.accessGeneration()!==generation)refused();
|
|
|
|
|
const currentRows=vault.list();
|
|
|
|
|
if(!Array.isArray(currentRows)||JSON.stringify(currentRows.map(row=>({id:row.id,origin:row.origin})))!==JSON.stringify(rows.map(row=>({id:row.id,origin:row.origin}))))refused();
|
|
|
|
|
if(verdict?.decision!=='allow'||verdict.coverage?.complete===false||/^(?:uninspectable|size-limit|inspection-incomplete|unavailable|timeout)$/.test(verdict.ruleId||''))refused();
|
|
|
|
|
// A denied export creates no usable references. References expire and cannot cross actors/policies.
|
|
|
|
|
for(const [key,row]of references)if(row.expires<=now()||!contextCurrent(row.token))references.delete(key);
|
|
|
|
|
if(references.size+Math.min(safe.length,32)>32)refused();
|
|
|
|
|
return safe.slice(0,32).map(row=>{const credentialRef=randomUUID();references.set(credentialRef,{...row,sessionId:c.sessionId,webContents:c.webContents,actor:{...c.actor},generation:vault.accessGeneration(),policy:c.policy,token:c.token,vault,expires:now()+60000});return {credentialRef,origin:row.origin};});
|
|
|
|
|
};
|
2026-10-13 21:00:41 +02:00
|
|
|
const fill=async(args={}, {signal}={},action='vault:fill')=>{
|
2026-10-13 19:14:32 +02:00
|
|
|
let credential;
|
|
|
|
|
try{
|
2026-10-13 21:00:41 +02:00
|
|
|
const c=snapshot(args,signal,action),vault=unlocked(c),ref=references.get(args.credentialRef);
|
|
|
|
|
const controlled=action==='vault:prepare-submit';if(controlled&&(!c.policy.passwordManager.controlledSubmission?.enabled||typeof prepareSubmission!=='function'))refused();
|
2026-10-13 19:14:32 +02:00
|
|
|
if(!idPattern.test(args.credentialRef||'')||!ref||ref.sessionId!==c.sessionId||ref.webContents!==c.webContents||ref.expires<=now()||(ref.actor.agentId!==c.actor.agentId||ref.actor.generation!==c.actor.generation)||ref.generation!==vault.accessGeneration()||ref.policy!==c.policy||ref.vault!==vault||!contextCurrent(ref.token)||c.foreground!==true||c.interactive===false||!c.policy.passwordManager.allowAutofill||!c.webContents||c.webContents.isDestroyed?.())refused();
|
|
|
|
|
const url=c.webContents.getURL(),u=new URL(url);
|
|
|
|
|
if(u.username||u.password||u.protocol!=='https:'||u.origin!==ref.origin||!allowed(ref.origin,c))refused();
|
|
|
|
|
const recheck=()=>{const next=check(c,args,signal);currentVault(c,args,signal,vault);if(vault.accessGeneration()!==ref.generation||next.foreground!==true||next.interactive===false||!next.policy.passwordManager.allowAutofill||c.webContents.isDestroyed?.()||c.webContents.getURL()!==url||ref.expires<=now())refused();};
|
|
|
|
|
if((await traceOperation('vault.fill.visual',()=>checkVisual(c)))?.decision!=='allow')refused();recheck();
|
|
|
|
|
await audit({action:'vault.fill-requested',outcome:'requested',sessionId:c.sessionId});recheck();
|
|
|
|
|
// Consume once before the native effect: failures never authorize an automatic retry.
|
|
|
|
|
references.delete(args.credentialRef);
|
|
|
|
|
credential={...vault.credential(ref.id,ref.origin),origin:ref.origin};recheck();
|
2026-10-13 21:00:41 +02:00
|
|
|
await beforeFill(c);recheck();c.webContents.focus();recheck();
|
|
|
|
|
const code=controlled?prepareFillScript(url,credential,c.policy.passwordManager.controlledSubmission.forms,Date.parse(c.policy.expiresAt)):fillScript(url,credential,Date.parse(c.policy.expiresAt));
|
|
|
|
|
const result=await traceOperation('vault.fill.native',()=>c.webContents.executeJavaScriptInIsolatedWorld(999,[{code}],false));
|
2026-10-13 19:14:32 +02:00
|
|
|
recheck();if(result?.completed!==true)refused();
|
2026-10-13 21:00:41 +02:00
|
|
|
await audit({action:'vault.filled',outcome:'completed',sessionId:c.sessionId});recheck();
|
|
|
|
|
if(controlled){const resultLease=await prepareSubmission({context:c,formId:result.formId,credential,vault,vaultGeneration:ref.generation});try{recheck();if(resultLease?.prepared!==true)refused();afterFill();return {completed:true,prepared:true};}catch(error){resultLease?.cancel?.();throw error;}}
|
|
|
|
|
afterFill();return {completed:true};
|
2026-10-13 19:14:32 +02:00
|
|
|
}catch{refused();}finally{if(credential){credential.username='';credential.password='';credential.origin='';}}
|
|
|
|
|
};
|
|
|
|
|
const safe=fn=>async(...args)=>{try{return await fn(...args);}catch{refused();}};
|
2026-10-13 21:00:41 +02:00
|
|
|
return {status:safe(status),list:safe(list),fill:safe(fill),prepare:safe((args,options)=>fill(args,options,'vault:prepare-submit')),clear:()=>references.clear()};
|
2026-10-13 19:14:32 +02:00
|
|
|
}
|
|
|
|
|
module.exports={createVaultAgentControls};
|