'use strict'; const {randomUUID}=require('node:crypto'); const {fillScript,prepareFillScript}=require('./password-fill.cjs'); 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; function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisual,inspect,audit,beforeFill=async()=>{},afterFill=()=>{},prepareSubmission,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}) { 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){ const fields=['vault:fill','vault:prepare-submit'].includes(action)?['sessionId','credentialRef']:['sessionId']; 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};}); }; const fill=async(args={}, {signal}={},action='vault:fill')=>{ let credential; try{ 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(); 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(); 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)); recheck();if(result?.completed!==true)refused(); 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}; }catch{refused();}finally{if(credential){credential.username='';credential.password='';credential.origin='';}} }; const safe=fn=>async(...args)=>{try{return await fn(...args);}catch{refused();}}; return {status:safe(status),list:safe(list),fill:safe(fill),prepare:safe((args,options)=>fill(args,options,'vault:prepare-submit')),clear:()=>references.clear()}; } module.exports={createVaultAgentControls};