From e8a654fb387c039217d754cd20b628660b2ac89a Mon Sep 17 00:00:00 2001 From: Karl Date: Tue, 13 Oct 2026 19:14:32 +0200 Subject: [PATCH] Add scoped human-unlocked vault agent autofill --- README.md | 2 +- apps/browser/agent-adapters.cjs | 2 +- apps/browser/agent-control.cjs | 17 ++++- apps/browser/diagnostics.cjs | 2 + apps/browser/main.cjs | 30 ++++++--- apps/browser/vault-agent-controls.cjs | 70 ++++++++++++++++++++ apps/browser/vault.cjs | 14 +++- apps/server/public/admin.js | 2 +- apps/server/public/agent-grants-admin.js | 2 +- apps/server/public/index.html | 2 +- docs/agent-vault-controls.md | 56 ++++++++++++++++ docs/requirements-audit.md | 41 ++++++++++++ packages/core/agent-audit.cjs | 2 +- packages/core/security.mjs | 2 +- scripts/agent-grants-history-admin-smoke.cjs | 10 +-- scripts/agent-grants-history-admin-smoke.mjs | 4 +- scripts/vault-agent-controls-smoke.cjs | 33 +++++++++ scripts/vault-agent-controls-smoke.mjs | 23 +++++++ scripts/verify-package.mjs | 2 +- tests/admin-controls-ui.test.mjs | 2 + tests/agent-control.test.mjs | 8 +++ tests/vault-agent-controls.test.mjs | 52 +++++++++++++++ tests/vault.test.mjs | 34 ++++++++++ 23 files changed, 383 insertions(+), 29 deletions(-) create mode 100644 apps/browser/vault-agent-controls.cjs create mode 100644 docs/agent-vault-controls.md create mode 100644 scripts/vault-agent-controls-smoke.cjs create mode 100644 scripts/vault-agent-controls-smoke.mjs create mode 100644 tests/vault-agent-controls.test.mjs diff --git a/README.md b/README.md index fef6fc8..4613ebf 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ S Browser uses translucent materials, rounded controls, light/dark themes, and a | Administration | Policy templates, diffs and history, independent publication review, groups, profiles, teaching workflows, incidents and configurable event retention. | | AI and agents | Approved AI routing with server-held credentials; scoped local agent navigation, tabs, page interaction, settings, bookmarks, inspected history and bounded browser window controls. Separate centrally approved agent identities, scoped credentials, expiry, generation rotation and targeted revocation; administrators can disable all agent access. | | Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Governed protected download metadata, completed clipboard/agent-click metadata and opt-in observed foreground clicks/form gestures. Full replay and content recording remain incomplete. | -| Additional controls | Local encrypted password vault with explicit autofill; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. | +| Additional controls | Local encrypted password vault with explicit autofill and separately approved agent status/masked inventory/fill after human unlock; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. | Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md). diff --git a/apps/browser/agent-adapters.cjs b/apps/browser/agent-adapters.cjs index f2c9c3d..138086d 100644 --- a/apps/browser/agent-adapters.cjs +++ b/apps/browser/agent-adapters.cjs @@ -153,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=['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 appActions=['vault:status','vault:list','vault:fill','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}={})=>{ diff --git a/apps/browser/agent-control.cjs b/apps/browser/agent-control.cjs index 8a9adfe..2851ee8 100644 --- a/apps/browser/agent-control.cjs +++ b/apps/browser/agent-control.cjs @@ -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(['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(['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,13 @@ 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 = { '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 = { '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.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');} + } 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');} @@ -55,6 +59,15 @@ function validate(body) { } // Never return adapter objects wholesale: session handles and sanitized text only. function project(action, result) { + 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]])); + } + if(action==='vault:list'){ + if(!Array.isArray(result)||result.length>32)throw fail(502,'Invalid vault response'); + return result.map(row=>{let origin;try{origin=new URL(row.origin);}catch{throw fail(502,'Invalid vault response');}if(typeof row.credentialRef!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(row.credentialRef)||typeof row.origin!=='string'||row.origin.length>2048||origin.protocol!=='https:'||origin.username||origin.password||origin.origin!==row.origin)throw fail(502,'Invalid vault response');return {credentialRef:row.credentialRef,origin:row.origin};}); + } + if(action==='vault:fill')return {completed:result?.completed===true}; 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'); diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs index d0bd431..090c00d 100644 --- a/apps/browser/diagnostics.cjs +++ b/apps/browser/diagnostics.cjs @@ -14,6 +14,8 @@ const names=new Set(['browser.window','browser.network','browser.ipc','browser.a 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); +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); 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); diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs index baef57c..7b76442 100644 --- a/apps/browser/main.cjs +++ b/apps/browser/main.cjs @@ -395,25 +395,29 @@ async function applyLibraryOrganization(input,options){ const result=await getLibraryOrganizer().apply(input,options); await sync();return result; } +async function inspectManagedAgentText(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; + if(context.policy.dlp.semantic.enabled||context.policy.security.yara.enabled){ + if(!localInspector)return {decision:'block',ruleId:'inspection-unavailable',coverage:{complete:false}}; + const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy)); + verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result; + } + if(verdict.decision!=='allow')return verdict; + const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external; +} 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,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; - if(context.policy.dlp.semantic.enabled||context.policy.security.yara.enabled){ - if(!localInspector)return {decision:'block'};const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy)); - verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result; - }if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external; - },audit:auditAgent,onInteraction:async metadata=>{ + inspect:inspectManagedAgentText,audit:auditAgent,onInteraction:async metadata=>{ 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}); }}); 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','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','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};} return original(args,context); }));} agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[], @@ -641,11 +645,19 @@ function groupNativeTab(input){requireManagedSection('library');if(libraryOrgani 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 vaultControls=require('./vault-agent-controls.cjs').createVaultAgentControls({ + getContext:id=>{const context=agentContext(id);return {...context,ready:context.ready&&featureAllowed(managedSettings(),'passwords'),actor:context.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};}, + contextCurrent:currentTabContext,getVault:requireVault,checkVisual:context=>checkVisual(tabs.get(context.sessionId)),inspect:inspectManagedAgentText, + audit:event=>audit(event.action),traceOperation:(operation,fn)=>diagnosticSpan('browser.vault',{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 { + 'vault:status':checked((args,options)=>vaultControls.status(args,options)), + 'vault:list':checked((args,options)=>vaultControls.list(args,options)), + 'vault:fill':checked((args,options)=>vaultControls.fill(args,options)), '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})), diff --git a/apps/browser/vault-agent-controls.cjs b/apps/browser/vault-agent-controls.cjs new file mode 100644 index 0000000..c2db7a2 --- /dev/null +++ b/apps/browser/vault-agent-controls.cjs @@ -0,0 +1,70 @@ +'use strict'; +const {randomUUID}=require('node:crypto'); +const {fillScript}=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,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=action==='vault:fill'?['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}={})=>{ + let credential; + try{ + const c=snapshot(args,signal,'vault:fill'),vault=unlocked(c),ref=references.get(args.credentialRef); + 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(); + c.webContents.focus();recheck(); + const result=await traceOperation('vault.fill.native',()=>c.webContents.executeJavaScriptInIsolatedWorld(999,[{code:fillScript(url,credential,Date.parse(c.policy.expiresAt))}],false)); + recheck();if(result?.completed!==true)refused(); + await audit({action:'vault.filled',outcome:'completed',sessionId:c.sessionId});recheck();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),clear:()=>references.clear()}; +} +module.exports={createVaultAgentControls}; diff --git a/apps/browser/vault.cjs b/apps/browser/vault.cjs index 778665b..e836e8a 100644 --- a/apps/browser/vault.cjs +++ b/apps/browser/vault.cjs @@ -49,11 +49,19 @@ function generatePassword(length = 32) { } function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) { if (typeof filePath !== 'string' || !path.isAbsolute(filePath) || typeof protect !== 'function' || typeof unprotect !== 'function' || !Number.isInteger(idleSeconds) || idleSeconds < 60 || idleSeconds > 900) fail(); - let key = null, entries = [], envelope = null, timer = null; + let key = null, entries = [], envelope = null, timer = null, accessGeneration = 0; + // Process-local capability epoch. Never persisted or included in public metadata. + // create/unlock begin with lock, so even reopening the same vault invalidates refs. + function invalidateAccess() { + if (accessGeneration >= Number.MAX_SAFE_INTEGER) { lock(); fail(); } + accessGeneration += 1; + } function lock() { clearTimeout(timer); timer = null; wipe(key); key = null; for (const entry of entries) { entry.password = ''; entry.username = ''; entry.label = ''; entry.origin = ''; } entries = []; envelope = null; + if (accessGeneration >= Number.MAX_SAFE_INTEGER) fail(); + accessGeneration += 1; } function touch() { clearTimeout(timer); timer = setTimeout(lock, idleSeconds * 1000); timer.unref(); } function requireUnlocked(refreshIdle = true) { if (!key) fail(); if (refreshIdle) touch(); } @@ -136,7 +144,7 @@ function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) { function status() { return { exists: fs.existsSync(filePath), locked: !key, idleSeconds, algorithm: 'AES-256-GCM / ML-KEM-1024 / scrypt / Windows DPAPI' }; } function list() { requireUnlocked(false); return entries.map(({ id, origin, username, label }) => ({ id, origin, username, label })); } function mutate(action) { - requireUnlocked(); const previous = entries.map(entry => ({ ...entry })); + requireUnlocked(); invalidateAccess(); const previous = entries.map(entry => ({ ...entry })); try { const result = action(); persist(); return result; } catch { entries = previous; fail(); } } function upsert(entry) { @@ -156,6 +164,6 @@ function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) { if (!entry || entry.origin !== expected) fail(); return { username: entry.username, password: entry.password }; } - return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword }; + return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword, accessGeneration: () => accessGeneration }; } module.exports = { createVault, generatePassword }; diff --git a/apps/server/public/admin.js b/apps/server/public/admin.js index b92b248..7ea6c55 100644 --- a/apps/server/public/admin.js +++ b/apps/server/public/admin.js @@ -6,7 +6,7 @@ let credential='',policy,presetPreview,managedBookmarks=[],personalBookmarks=[], const managedSections=['appearance','library','ai','passwords','documentScan','agents']; -const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control']; +const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill']; function status(message,error=false){$('status').textContent=message;$('status').className=error?'error':'';} diff --git a/apps/server/public/agent-grants-admin.js b/apps/server/public/agent-grants-admin.js index a86e779..6250d74 100644 --- a/apps/server/public/agent-grants-admin.js +++ b/apps/server/public/agent-grants-admin.js @@ -13,7 +13,7 @@ function renderAgentGrants(){ const enabled=agentGrantField(grid,'Enabled');enabled.type='checkbox';enabled.checked=grant.enabled;enabled.disabled=!agentGrantsEditable;enabled.addEventListener('change',()=>{grant.enabled=enabled.checked;changedAgentGrants();}); const expiry=agentGrantField(grid,'Expiry (UTC)');expiry.type='text';expiry.value=grant.expiresAt;expiry.placeholder='2026-12-31T23:59:59Z';expiry.disabled=!agentGrantsEditable;expiry.addEventListener('input',()=>{grant.expiresAt=expiry.value.trim();changedAgentGrants();}); const hosts=agentGrantField(grid,'Additional destination allowlist','textarea');hosts.value=grant.allowedHosts.join('\n');hosts.placeholder='Empty adds no destination restriction';hosts.disabled=!agentGrantsEditable;hosts.addEventListener('input',()=>{grant.allowedHosts=hosts.value.split('\n').map(v=>v.trim()).filter(Boolean);changedAgentGrants();}); - const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox); + const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. Vault status, masked listing and exact-origin autofill require separate capabilities and human unlock. Agents cannot unlock the vault or export passwords. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox); for(const scope of agentScopes){const label=document.createElement('label');const check=document.createElement('input');check.type='checkbox';check.checked=grant.scopes.includes(scope);check.disabled=!agentGrantsEditable;label.append(check,document.createTextNode(scope));scopes.append(label);check.addEventListener('change',()=>{grant.scopes=agentScopes.filter(s=>s===scope?check.checked:grant.scopes.includes(s));changedAgentGrants();});} const actions=document.createElement('div');actions.className='row';const generation=document.createElement('span');generation.textContent=`Credential generation: ${grant.generation}`;const rotate=document.createElement('button');rotate.type='button';rotate.className='secondary';rotate.textContent='Invalidate previous credentials';rotate.disabled=!agentGrantsEditable||grant.generation>=Number.MAX_SAFE_INTEGER;rotate.addEventListener('click',()=>{grant.generation+=1;renderAgentGrants();changedAgentGrants();agentGrantStatus('Credential generation changed in draft. Publish after review, then pair this identity again locally.');});const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='Revoke grant';revoke.disabled=!agentGrantsEditable||!grant.enabled;revoke.addEventListener('click',()=>{grant.enabled=false;renderAgentGrants();changedAgentGrants();agentGrantStatus('Grant disabled in draft. Publish after review to apply revocation.');});actions.append(generation,rotate,revoke); if(!agentGrantsPublishedIds.has(grant.id)){const remove=document.createElement('button');remove.type='button';remove.className='secondary';remove.textContent='Remove unpublished grant';remove.disabled=!agentGrantsEditable;remove.addEventListener('click',()=>{agentGrantsDraft=agentGrantsDraft.filter(g=>g!==grant);renderAgentGrants();changedAgentGrants();});actions.append(remove);} diff --git a/apps/server/public/index.html b/apps/server/public/index.html index 240edb0..3e10b9e 100644 --- a/apps/server/public/index.html +++ b/apps/server/public/index.html @@ -12,7 +12,7 @@

IDENTITY AND FEATURE ACCESS

User classes and website access

Create profiles for any user class with your own names and controls. Employee, developer and high-protection templates are optional starting points. Assign profiles to verified enterprise subjects or explicit managed devices. Asset-owner labels are not authenticated identities. Website denials and category restrictions accumulate across matching profiles; an allowlist does not bypass a denial.

ProfileAssignmentsDeveloper toolsAction

Create profile

Explicit device assignments
Denied domain categories

Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.

Preview effective device policy

Central categories and developer delegation

Categories contain your approved domain lists. This is not an automatic external website reputation feed. Enabling delegation permits reviewed developer-profile grants; denying profiles still win.

Website categories

Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.

Assignment visibility

DeviceVerified subjectEffective profiles

CONTROLLED DEPLOYMENT

Groups and pilot rollouts

Apply additional restrictions to explicit device groups. Start with selected pilot devices, inspect their reported policy versions, then explicitly expand the cohort. Group rules can tighten the baseline; they cannot weaken it. No automatic promotion occurs.

GroupMembersStageActions

Create a device group

Group members
Rollout cohort

Pilot and expanded devices must belong to the group. All means all group members, not the entire fleet.

Additional restrictions
Settings section restrictions

Membership, restrictions and cohort changes are signed policy changes. Mandatory independent approval applies to these changes too.

Additional company bookmarks

These managed bookmarks are added for the selected rollout cohort. They do not replace baseline company bookmarks.

TitleURLFolderAction

Effective policy preview

Reported rollout state

DeviceEffective groupsReported / target versionVersion acknowledgmentLast contact

Acknowledgment compares the device-reported version with its target. It is not security attestation or proof that every control is functioning. Last contact is shown separately.

INDEPENDENT CHANGE CONTROL

Policy approval queue

Loading approval requirements.

Prepared changes remain inactive until approved. A different administrator must review the exact payload and base version. Stale changes must be prepared again against the latest policy.

PreparedAuthorChangeBase versionStatusReview

CHANGE CONTROL

Policy history and recovery

Review persisted signed versions and compare changes. Restoring reapplies the selected security rules, permissions, bookmarks and network settings, group memberships and rollout cohorts. The original version stays preserved; restoration publishes a new version with a fresh expiry. Devices never receive a lower policy version.

VersionPublishedActorReview

Managed browser settings

Hide settings or deny changes centrally. These permissions are enforced in the native browser process as well as the interface. Hiding a section denies its user actions; publishing a new signed policy applies changes to connected devices at refresh.

SectionHide and deny accessLock user actions
Appearance
Bookmarks and tab groups
AI workspace
Password manager
Manual document scan
Local agent controls

Appearance values become mandatory when appearance is hidden, locked, or settings access is disabled. Otherwise users may choose local preferences. Vault locking remains available to keep credentials safe. Mandatory security inspection continues independently of settings visibility. Publish signed policy above to apply.

COMPANY LIBRARY

Managed bookmarks

Publish intranet and approved service links to every browser. Company bookmarks are read-only for employees. Personal bookmarks remain separate; configure whether employees may add, edit or delete them.

AI library organization

Suggestions require the approved AI gateway and Library permissions. Agents also require the organization scopes below. Company bookmarks remain protected. Keep automatic application disabled to require explicit confirmation before changes.

TitleHTTPS / HTTP URL (no query or fragment)FolderActions

Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.

Device personal bookmarks

Authorized administrators can review and remotely edit a device's synchronized personal bookmarks. Changes are applied when the browser next refreshes management state. Titles and URLs are never included in security audit events.

TEACH YOUR BROWSER

Define what must stay inside

Add phrases that must never leave, or register an exact file fingerprint. Reference files stay on your computer; only their SHA-256 fingerprint enters policy.

Local semantic reference protection

Describe a confidential category. Your approved local EmbeddingGemma 2 runtime creates pinned text reference vectors. Document extraction and local OCR text can be compared against these references. This workflow does not train a model or provide native video inspection.

Only normalized reference vectors and coverage features enter the policy draft. No document, extracted text or model fine tuning is retained. Complete extraction and chunk coverage are required; publish explicitly to approve.

Requires locally provisioned model weights, an exact model revision, matching dimensions and the document-v1 profile. Similarity is a detector score, not a confidence guarantee. Incomplete extraction or chunk coverage always blocks.

-

Enterprise security and local agents

Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.

Agent capabilities

History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.

Individual agent identities

Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.

Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.

Advanced source configuration: YARA, Sigma and visual references

Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.

Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.

Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.

+

Enterprise security and local agents

Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.

Agent capabilities

History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.

Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.

Individual agent identities

Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.

Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.

Advanced source configuration: YARA, Sigma and visual references

Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.

Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.

Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.

Governed document teaching

Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.

Local password vault

Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.

Publish signed policy to apply these controls.

Visual impersonation protection

For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.

Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.

Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.

Managed clipboard and ClickFix protection

Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.

Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.

DEVICE FLEET

Enrollment and access

Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.

DevicePlatformIdentityPolicyLast seenAccess

ADMINISTRATOR ACCESS

Active management sessions

Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.

Session inventory requires the administrator role.

IdentityRoleExpiresAction

SECURITY OPERATIONS

Incident triage

Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.

CreatedSecurity eventStatusSeverityAssigneeReview

AUDIT LIFECYCLE

Database event retention

Loading deployment configuration.

Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.

SECURITY MONITORING

Recent audit events

TimeEventDeviceMetadata

This view shows the latest 100 events. Prompts and document contents are excluded. Exports contain up to 1,000 events; cursor-based APIs and durable HTTPS delivery support continuous SIEM ingestion.

COMPANY EXPERIENCE

Company logo and start page

Roll out your company identity through signed policy. Name and accent above apply alongside this logo.

Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.

Download inspection

Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.

PROTECTED SECURITY EVIDENCE

Protected evidence collection

Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.

Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.

Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, action and bounded target metadata. Guarded clipboard and agent effects are marked completed; trusted foreground page clicks and activated form events are marked observed, which does not prove network transmission. Sensitive forms, page paths, values, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Collection is bounded and limited to the active main-frame page; it is not a complete replay. Publish and review this draft through the normal policy workflow.

OBSERVABILITY

Browser OpenTelemetry

Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.

HTTPS is required except for a numeric loopback collector. Queue and export limits remain enforced. Browser logs and traces use OTLP. Durable security events are separately delivered by the management service in ECS or CEF; its HTTPS destination, TLS certificates and credentials are configured by the server operator, not exposed in browser policy. Backend service traces use the server OpenTelemetry configuration. Endpoint credentials do not belong in policy URLs. Disable and publish to stop new collection.

Review policy changes

Review the changed policy paths before publication. Existing server validation and version checks remain authoritative.

diff --git a/docs/agent-vault-controls.md b/docs/agent-vault-controls.md new file mode 100644 index 0000000..74fa681 --- /dev/null +++ b/docs/agent-vault-controls.md @@ -0,0 +1,56 @@ +# Controlled agent password autofill + +Administrators separately approve `vault:status`, `vault:list` and `vault:fill` +in signed baseline policy and the individual grant. All start unchecked. Password +manager enablement, feature visibility/locks, autofill policy, user-profile rules, +actor destination scope, expiry and targeted revocation remain enforced. + +| Action | Arguments | Output | +| --- | --- | --- | +| `vault:status` | Optional native `sessionId` | exists, locked, enabled and allowAutofill booleans | +| `vault:list` | Optional native `sessionId` | Up to 32 opaque credential references and approved HTTPS origins | +| `vault:fill` | Native `sessionId`, returned `credentialRef` | completed boolean | + +The user creates or unlocks the vault through the trusted browser interface. +Agents cannot unlock, create, export, save, delete or retrieve plaintext passwords. +Inventory excludes usernames, labels, underlying record IDs and passwords. Origin +metadata passes the same local/enterprise inspection pipeline before export. +References expire after 60 seconds, are consumed once and are bound to actor, +policy, exact native tab/WebContents, context and private vault generation. +The reference pool is bounded at 32; this is not a complete paginated vault manager. + +For filling, keep the target login tab visible and request a fresh inventory after +unlock. Use its reference only at the saved exact HTTPS origin. A single visible +same-origin, same-window POST login form is required. Visual protection, foreground +identity, policy, cancellation and lock state are checked again after waits. +Passwords pass only into the fixed isolated-world fill script. No arbitrary +selector, password value or script is accepted from the agent. Filling does not +submit the form, and a failed response never implies undo or authorizes a retry. + +Tab changes and policy updates already lock the vault. Unlock again through the +trusted UI and refresh references after either event. Lock/reopen and mutation +attempts advance a private process-local generation; even a same-ID password +replacement invalidates older references. Status/inventory polling does not renew +the idle unlock deadline. This generation never appears in public responses. + +Durable audit records pre-execution and completed/failed agent actions with verified +principal attribution, plus vault fill-requested/filled lifecycle events. Ordinary +audit, SIEM and diagnostic spans contain no credential values, references or native +record IDs. Debug spans separately name inventory inspection, visual authorization +and native fill under the protected RPC action. + +Actual Windows/Electron proof uses a controlled HTTPS receiver with trust pinned +to its exact synthetic certificate in the isolated test session. It verifies human +unlock, real field filling, zero submitted requests, host filtering, actor/ref +boundaries, replay, relock/reopen, password replacement, central autofill disable, +targeted revocation and named exported OTLP phases. It does not prove production +certificate deployment or successful login submission. The separate tab-binding +unit test holds the vault unlocked to verify binding independently of automatic +tab-change locking (`artifacts/vault-agent-controls-native-proof.json`). + +Outbound DLP still scans a later form submission. A credential matching a secret +rule can therefore block an otherwise legitimate login; there is no generic +password-field exception. A future governed allowance must bind one exact +submission and inspect every remaining field. Persistent sessions, file/vault +mutations, safe screenshots, broader administration and production key custody +remain separate open requirements. diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md index 0767736..dd1a395 100644 --- a/docs/requirements-audit.md +++ b/docs/requirements-audit.md @@ -609,3 +609,44 @@ Next control milestones are persistent session restoration and scoped vault/file operations; history inventory/navigation and the four bounded window operations are now implemented, rather than remaining wholly absent. The overall objective remains active and unachieved. + +## Controlled vault-agent capability increment + +Separately signed default-off status/list/fill scopes now extend the existing +DPAPI/PQ agent interface. Inventory exports only approved inspected HTTPS origins +and one-use references; username, label, record ID and password stay private. +Human unlock is required. References bind exact actor, policy, native session, +WebContents and internal vault generation. Lock/reopen and same-ID password +changes invalidate them; metadata polling does not postpone idle locking. +Shared inspection retains local/enterprise DLP and explicitly marks a missing +required local inspector incomplete. See `docs/agent-vault-controls.md`. + +Actual native HTTPS field-fill proof covers separate actors, scoped inventory, +reference replay, lock/reopen, password replacement, central autofill disable and +targeted revocation with zero form submissions. Tab changes/policy updates lock +the vault already; fixture corrections explicitly unlock through trusted UI rather +than weakening enforcement. A separate unit test proves same-origin tab binding +with the vault unlocked. Trace `fd47bcfc8b259712101ad3c81c1d95c1` contains inventory +inspection; `51ce8d1d2bee33718b442197a044340b` contains visual/native fill phases with +verified action parent correlation. Collector rejected writes were zero. + +Actual central UI verifies all three scopes unchecked initially, readonly auditor, +independent publication and history restoration. All 149 completed API requests +have correlated backend spans; two canceled GET requests remain separately +reported. No mutation is treated as passed without completion/export evidence. +The full suite passes 583 tests and syntax checks pass. + +This is not complete password-manager administration: no agent unlock/export or +vault/file mutation, and inventory/reference pool is bounded at 32. Legitimate +credential submission remains unverified and can still be blocked by normal DLP. +Persistent sessions, safely inspected visual export, worker ownership and the +previous production/external integration gates remain open. The overall goal is +not complete. + +Final current Windows build and packaged-source/runtime verification pass, +including the new vault broker and private generation, contained scanner, +offline semantic engine, upload leases and both protected agent clients. +The native tab/DLP/revocation proof passes. Logs: +`artifacts/vault-agent-final-windows-build.log`, `vault-agent-final-package-proof.log`, +`vault-agent-native-tabs.log`, `vault-agent-syntax.log`. +The executable remains unsigned; no signed production installer is claimed. diff --git a/packages/core/agent-audit.cjs b/packages/core/agent-audit.cjs index c5a9db9..0d4e0d2 100644 --- a/packages/core/agent-audit.cjs +++ b/packages/core/agent-audit.cjs @@ -1,6 +1,6 @@ 'use strict'; const {z}=require('zod'); -const application=['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 application=['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 AGENT_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(application.map(action=>[action,action])),'agent-request':'agent:request'}); const AgentAuditSchema=z.object({action:z.enum(Object.keys(AGENT_SCOPES)),scope:z.enum([...new Set(Object.values(AGENT_SCOPES))]),outcome:z.enum(['authorized','completed','denied','failed']),sessionId:z.string().uuid().optional(),principalId:z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/).optional(),credentialGeneration:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional()}).strict().superRefine((value,ctx)=>{ if((value.principalId!==undefined)!==(value.credentialGeneration!==undefined))ctx.addIssue({code:'custom',message:'Agent principal attribution requires both identity and generation'}); diff --git a/packages/core/security.mjs b/packages/core/security.mjs index 00f257c..2ff6b96 100644 --- a/packages/core/security.mjs +++ b/packages/core/security.mjs @@ -1,6 +1,6 @@ import { z } from 'zod'; import { isIP } from 'node:net'; -export const AgentScopes=['history:list','history:go','window:state','window:control','sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']; +export const AgentScopes=['vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','screenshot','ai','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 host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i); export const VisualOriginSchema=z.string().max(2048).refine(value=>{try{const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash&&u.origin===value&&!u.hostname.includes('*')&&!u.hostname.endsWith('.')&&!isIP(u.hostname.replace(/^\[|\]$/g,''))&&/^[a-z0-9.-]+$/.test(u.hostname);}catch{return false;}},'Expected a canonical exact HTTPS domain origin'); export const VisualViewportSchema=z.object({width:z.number().int().min(16).max(8192),height:z.number().int().min(16).max(8192)}).strict(); diff --git a/scripts/agent-grants-history-admin-smoke.cjs b/scripts/agent-grants-history-admin-smoke.cjs index 410b19a..d736924 100644 --- a/scripts/agent-grants-history-admin-smoke.cjs +++ b/scripts/agent-grants-history-admin-smoke.cjs @@ -8,12 +8,12 @@ async function capture(file,section){await evaluate(`document.getElementById('${ app.whenReady().then(async()=>{ window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}}); const proofTraceIds=[],requestsById=new Map();session.defaultSession.webRequest.onCompleted({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},details=>{const row=requestsById.get(details.id);if(row){row.outcome='completed';row.statusCode=details.statusCode;}});session.defaultSession.webRequest.onErrorOccurred({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},details=>{const row=requestsById.get(details.id);if(row)row.outcome='failed';});session.defaultSession.webRequest.onBeforeSendHeaders({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},(details,callback)=>{const crypto=require('node:crypto'),traceId=crypto.randomBytes(16).toString('hex'),spanId=crypto.randomBytes(8).toString('hex');const path=new URL(details.url).pathname,route=path==='/api/admin/policy'?'/api/admin/policy':path==='/api/admin/policy/restore'?'/api/admin/policy/restore':/^\/api\/admin\/policy-reviews\/[^/]+\/approve$/.test(path)?'/api/admin/policy-reviews/:id/approve':/^\/api\/admin\/policy-history\/[^/]+\/restore$/.test(path)?'/api/admin/policy-history/:id/restore':'other-api';const row={traceId,parentSpanId:spanId,outcome:'pending',method:details.method,route};proofTraceIds.push(row);requestsById.set(details.id,row);callback({requestHeaders:{...details.requestHeaders,traceparent:'00-'+traceId+'-'+spanId+'-01'}});}); - await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');for(const scope of ['history:list','history:go','window:state','window:control'])assert.deepEqual(await evaluate(`(()=>{const field=document.getElementById('agent-scope-${scope}');return {present:!!field,checked:field.checked,disabled:field.disabled};})()`),{present:true,checked:false,disabled:false});await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`); - assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor .checks input')).every(x=>!x.checked)`),true); - await evaluate(`const box=document.querySelector('#agent-grants-editor>fieldset'),fields=box.querySelectorAll('.grid input,.grid textarea');fields[0].value='research-agent';fields[0].dispatchEvent(new Event('input'));fields[1].value='Research Agent';fields[1].dispatchEvent(new Event('input'));fields[2].checked=true;fields[2].dispatchEvent(new Event('change'));fields[3].value='2099-12-31T23:59:59Z';fields[3].dispatchEvent(new Event('input'));fields[4].value='*.company.example';fields[4].dispatchEvent(new Event('input'));for(const label of box.querySelectorAll('.checks label'))if(['sessions','read'].includes(label.textContent)){const input=label.querySelector('input');input.checked=true;input.dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true); + await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');for(const scope of ['history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill'])assert.deepEqual(await evaluate(`(()=>{const field=document.getElementById('agent-scope-${scope}');return {present:!!field,checked:field.checked,disabled:field.disabled};})()`),{present:true,checked:false,disabled:false});await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;for(const scope of ['vault:status','vault:list','vault:fill'])document.getElementById('agent-scope-'+scope).checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`); + assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor .checks input')).every(x=>!x.checked)`),true);assert.equal(await evaluate(`document.getElementById('agent-vault-help').textContent.includes('A person must unlock')&&document.getElementById('agent-vault-help').textContent.includes('cannot unlock it or export passwords')`),true);assert.equal(await evaluate(`document.getElementById('agent-grants-editor').textContent.includes('human unlock')`),true); + await evaluate(`const box=document.querySelector('#agent-grants-editor>fieldset'),fields=box.querySelectorAll('.grid input,.grid textarea');fields[0].value='research-agent';fields[0].dispatchEvent(new Event('input'));fields[1].value='Research Agent';fields[1].dispatchEvent(new Event('input'));fields[2].checked=true;fields[2].dispatchEvent(new Event('change'));fields[3].value='2099-12-31T23:59:59Z';fields[3].dispatchEvent(new Event('input'));fields[4].value='*.company.example';fields[4].dispatchEvent(new Event('input'));for(const label of box.querySelectorAll('.checks label'))if(['sessions','read','vault:status','vault:list','vault:fill'].includes(label.textContent)){const input=label.querySelector('input');input.checked=true;input.dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true); assert.equal(await evaluate(`fetch('/api/admin/policy-reviews/'+selectedIndependentReview.id+'/approve',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({payloadHash:selectedIndependentReview.payloadHash,expectedVersion:selectedIndependentReview.expectedVersion,approve:true})}).then(r=>r.status)`),403); - await identity('grant-reviewer');await selectReview();const diff=await evaluate(`document.getElementById('independent-review-diff').textContent`);for(const text of ['/agents/grants','research-agent','2099','company.example','sessions','read','generation'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2); - await identity('grant-auditor');assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true); + await identity('grant-reviewer');await selectReview();const diff=await evaluate(`document.getElementById('independent-review-diff').textContent`);for(const text of ['/agents/grants','research-agent','2099','company.example','sessions','read','generation','vault:status','vault:list','vault:fill'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2); + await identity('grant-auditor');for(const scope of ['vault:status','vault:list','vault:fill'])assert.equal(await evaluate(`document.getElementById('agent-scope-${scope}').disabled`),true);assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true); await identity('grant-author');assert.equal(await evaluate(`document.querySelector('#agent-grants-editor .grid input').readOnly`),true);await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor button')).find(x=>x.textContent==='Invalidate previous credentials').click();document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2');await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('"generation":2'));await approve(3); await identity('grant-author');await evaluate(`document.getElementById('brand-name').value='Unpublished preserved draft';document.getElementById('history-load').click()`);await waitFor(`document.querySelectorAll('#history-rows tr').length===3`);await evaluate(`document.querySelectorAll('#history-rows button')[1].click()`);await waitFor(`!document.getElementById('history-review').hidden`);assert.equal(await evaluate(`document.getElementById('brand-name').value`),'Unpublished preserved draft');assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');assert.equal(await evaluate(`document.getElementById('history-restore').disabled`),true);const history=await evaluate(`document.getElementById('history-diff-list').textContent`);assert.ok(history.includes('/agents/grants'));assert.ok(history.includes('generation'));assert.ok((await evaluate(`document.getElementById('history-review-summary').textContent`)).includes('does not restore invalidated credentials'));await capture('artifacts/agent-grants-history-admin.png','history-review');await evaluate(`document.getElementById('history-approve').checked=true;document.getElementById('history-approve').dispatchEvent(new Event('change'));document.getElementById('history-restore').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('research-agent'));await approve(4); for(let n=0;n<100&&proofTraceIds.some(row=>row.outcome==='pending');n++)await new Promise(r=>setTimeout(r,50));assert.equal(proofTraceIds.some(row=>row.outcome==='pending'),false,'API requests must settle before telemetry shutdown'); diff --git a/scripts/agent-grants-history-admin-smoke.mjs b/scripts/agent-grants-history-admin-smoke.mjs index 8b927f1..2e193a7 100644 --- a/scripts/agent-grants-history-admin-smoke.mjs +++ b/scripts/agent-grants-history-admin-smoke.mjs @@ -16,6 +16,6 @@ try { const config={publicUrl:'http://127.0.0.1',secure:false,requireIndependentPolicyApproval:true};server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));config.publicUrl=`http://127.0.0.1:${server.address().port}`; const env={...process.env,SAFETYNET_ADMIN_SMOKE_PROFILE:join(directory,'browser'),SAFETYNET_ADMIN_SMOKE_URL:config.publicUrl};delete env.ELECTRON_RUN_AS_NODE; child=spawn(require('electron'),['scripts/agent-grants-history-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',d=>{if(String(d).includes('GRANT_ADMIN_OK'))console.log('Grant admin actual UI passed');});child.stderr.on('data',d=>{if(String(d).includes('GRANT_ADMIN_FAILED'))console.error(String(d).trim());});const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0); - const p=verifyPolicy(store.envelope(),store.publicKey());assert.equal(p.version,4);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual'); - const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[],failedAttempts=traces.filter(item=>item.outcome==='failed');assert.ok(traces.some(item=>item.outcome==='completed'),'Completed API requests required');assert.equal(traces.some(item=>item.outcome!=='completed'&&['POST','PUT','PATCH','DELETE'].includes(item.method)),false,'Every policy mutation must complete and export a correlated span');for(const item of traces.filter(item=>item.outcome==='completed')){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);assert.equal(r.ok,true,'Completed API request trace must be available');if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified'); + const p=verifyPolicy(store.envelope(),store.publicKey());assert.equal(p.version,4);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual');for(const scope of ['vault:status','vault:list','vault:fill']){assert.ok(p.agents.scopes.includes(scope));assert.ok(p.agents.grants[0].scopes.includes(scope));} + const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[],failedAttempts=traces.filter(item=>item.outcome==='failed');assert.ok(traces.some(item=>item.outcome==='completed'),'Completed API requests required');assert.equal(traces.some(item=>item.outcome!=='completed'&&['POST','PUT','PATCH','DELETE'].includes(item.method)),false,'Every policy mutation must complete and export a correlated span');for(const item of traces.filter(item=>item.outcome==='completed')){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);assert.equal(r.ok,true,'Completed API request trace must be available');if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,vaultScopesDefaultOffAndReviewed:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified'); } finally {child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-agent-grant-admin-'))throw Error('Unsafe cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});} diff --git a/scripts/vault-agent-controls-smoke.cjs b/scripts/vault-agent-controls-smoke.cjs new file mode 100644 index 0000000..cd8dea1 --- /dev/null +++ b/scripts/vault-agent-controls-smoke.cjs @@ -0,0 +1,33 @@ +'use strict'; +const {app,BrowserWindow,session}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),{X509Certificate}=require('node:crypto'); +app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs'); +const wait=ms=>new Promise(r=>setTimeout(r,ms)),base=process.env.SAFETYNET_SMOKE_SERVER,origin=process.env.SAFETYNET_SMOKE_ORIGIN,headers={authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`,'content-type':'application/json'}; +async function until(fn){for(let i=0;i<200;i++){const r=await fn();if(r)return r;await wait(100);}throw Error('Vault native fixture timed out');} +const overview=async()=> (await fetch(base+'/api/admin/overview',{headers})).json(); +app.whenReady().then(async()=>{const pages=[];let stage='startup';try{ + const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;}); + const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256; + // Only the exact synthetic certificate on loopback is trusted, in this isolated fixture partition. + session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,callback)=>callback(req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint?0:-2)); + const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(result.ok,true,method);return result.data;}; + stage='enrollment';await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});const page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(page);await until(()=>!page.isLoading()&&page.getURL()===origin+'/login'); + const a=await ui('agentConnection','vault-a'),b=await ui('agentConnection','vault-b'),requestFile=path.join(app.getPath('userData'),'vault-request.json'); + async function call(connection,action,args={}){fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};} + const sessionId=(await call(a,'sessions')).result[0].id; + stage='locked';assert.equal((await call(a,'vault:status',{sessionId})).result.locked,true);assert.equal((await call(a,'vault:list',{sessionId})).exit,1); + const master='Synthetic vault master for native proof only',username='native-vault-private-user',password='native-vault-private-password',label='native-vault-private-label'; + stage='human create';await ui('vaultCreate',master);const saved=await ui('vaultSave',{origin,username,password,label});await ui('vaultSave',{origin:'https://127.0.0.2',username,password,label}); + const inventory=async()=>{const r=await call(a,'vault:list',{sessionId});assert.equal(r.exit,0);assert.equal(r.result.length,1);assert.equal(r.result[0].origin,origin);for(const sentinel of [username,password,label,saved.id])assert.ok(!JSON.stringify(r.result).includes(sentinel));return r.result[0].credentialRef;}; + stage='cross actor';let ref=await inventory();assert.equal((await call(b,'vault:fill',{sessionId,credentialRef:ref})).exit,1); + stage='actual fill';win.focus();page.focus();await wait(150);assert.equal((await call(a,'vault:fill',{sessionId,credentialRef:ref})).exit,0);assert.equal(await page.executeJavaScript(`document.querySelector('input[name=username]').value===${JSON.stringify(username)}&&document.querySelector('input[type=password]').value===${JSON.stringify(password)}`),true);assert.equal((await call(a,'vault:fill',{sessionId,credentialRef:ref})).exit,1); + stage='other tab';ref=await inventory();await ui('createTab',{url:origin+'/login'});const other=win.contentView.children.find(v=>v.webContents!==win.webContents&&v.webContents!==page).webContents;pages.push(other);await until(()=>!other.isLoading());const otherId=(await call(a,'sessions')).result.find(row=>row.id!==sessionId).id;assert.equal((await call(a,'vault:fill',{sessionId:otherId,credentialRef:ref})).exit,1);assert.equal(await other.executeJavaScript('document.querySelector("input[type=password]").value'), '');await ui('activateTab',sessionId);await ui('vaultUnlock',master);win.focus();page.focus(); + stage='inventory after tab restore';await wait(300);ref=await inventory();stage='vault relock';await ui('vaultLock');await ui('vaultUnlock',master);assert.equal((await call(a,'vault:fill',{sessionId,credentialRef:ref})).exit,1); + stage='password replacement';ref=await inventory();await ui('vaultSave',{id:saved.id,origin,username,password:password+'-changed',label});assert.equal((await call(a,'vault:fill',{sessionId,credentialRef:ref})).exit,1); + const change=async modify=>{const p=(await overview()).policy;modify(p);p.version++;const proposed=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposed.status,202);const review=await proposed.json();const approval=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:`sn_session=${process.env.SAFETYNET_SMOKE_REVIEWER}`,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approval.status,200);await until(async()=>{const s=await ui('state');return s.ready&&s.policyVersion===p.version;});}; + stage='central disable';ref=await inventory();await change(p=>{p.passwordManager.allowAutofill=false;});assert.equal((await call(a,'vault:fill',{sessionId,credentialRef:ref})).exit,1);await change(p=>{p.passwordManager.allowAutofill=true;});await ui('vaultUnlock',master); + stage='no submit';assert.equal((await page.executeJavaScript(`fetch('/count').then(r=>r.json())`)).submitted,0);stage='target revoke';ref=await inventory();await change(p=>{p.agents.grants.find(g=>g.id==='vault-a').enabled=false;});assert.equal((await call(a,'vault:fill',{sessionId,credentialRef:ref})).exit,1); + stage='audit trace';const events=await until(async()=>{const e=(await overview()).events;return e.some(e=>e.metadata.agent?.action==='vault:fill'&&e.metadata.agent.outcome==='completed')&&e;});for(const event of events)for(const sentinel of [master,username,password,label,saved.id,ref])assert.ok(!JSON.stringify(event).includes(sentinel)); + await wait(1500);const proofs=[];for(const action of ['vault:list','vault:fill']){const event=events.find(e=>e.metadata.agent?.action===action&&e.metadata.agent.outcome==='completed'&&e.metadata.traceId);assert.ok(event);const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.metadata.traceId)).json(),spans=trace.spans||[];const request=spans.find(s=>s.operation==='agent.request'),dispatch=spans.find(s=>s.operation==='agent.dispatch'&&s.parentSpanId===request?.spanId),agent=spans.find(s=>s.name==='browser.agent'&&s.operation===action&&s.parentSpanId===dispatch?.spanId);assert.ok(agent);for(const operation of action==='vault:list'?['vault.inventory.inspect']:['vault.fill.visual','vault.fill.native'])assert.ok(spans.some(s=>s.name==='browser.vault'&&s.operation===operation&&spans.some(p=>p.spanId===s.parentSpanId)));proofs.push({action,traceId:event.metadata.traceId,stages:spans.map(({name,operation,parentSpanId,spanId,durationMs})=>({name,operation,parentSpanId,spanId,durationMs}))});} + const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);const summary=await(await fetch('http://127.0.0.1:4318/api/summary')).json(); + fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'vault-agent-controls-native-proof.json'),JSON.stringify({actualElectron:true,syntheticExactCertificateTrustOnly:true,humanUnlockOnly:true,maskedInventory:true,hostScopeFilter:true,crossActorDenied:true,actualNativeFill:true,fillNeverSubmits:true,replayDenied:true,differentNativeTabDenied:true,reunlockInvalidates:true,passwordReplacementInvalidates:true,centralAutofillDisable:true,targetedRevoke:true,ordinaryAuditSecretFree:true,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites},summary,proofs},null,2));console.log('Vault agent controls native proof passed');for(const p of pages)require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p);app.exit(0); + }catch(error){fs.writeFileSync(path.resolve('artifacts/vault-agent-controls-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message},null,2));console.error(`Vault fixture failed at ${stage}: ${error.name}`);for(const p of pages)if(!p.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p);app.exit(1);}}); diff --git a/scripts/vault-agent-controls-smoke.mjs b/scripts/vault-agent-controls-smoke.mjs new file mode 100644 index 0000000..bf46df5 --- /dev/null +++ b/scripts/vault-agent-controls-smoke.mjs @@ -0,0 +1,23 @@ +import {createServer} from 'node:https'; +import {createRequire} from 'node:module'; +import {mkdtempSync,mkdirSync,rmSync,readFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join,resolve,sep} from 'node:path'; +import {spawn} from 'node:child_process'; +import {randomBytes} from 'node:crypto'; +import {createApp} from '../apps/server/app.mjs'; +import {openStore,digest} from '../apps/server/store.mjs'; +import {startTelemetry} from '../apps/server/telemetry.mjs'; +const collector='http://127.0.0.1:4318';if(!(await fetch(collector+'/health')).ok)throw Error('Protected collector unavailable'); +process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry(); +const temporary=mkdtempSync(join(tmpdir(),'safetynet-vault-agent-'));mkdirSync('artifacts',{recursive:true}); +if(!resolve(temporary).startsWith(resolve(tmpdir())+sep)||!temporary.split(/[\\/]/).at(-1).startsWith('safetynet-vault-agent-'))throw Error('Unsafe temporary fixture path'); +let submitted=0;const sink=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.method==='POST')submitted++;if(req.url==='/count'){res.setHeader('content-type','application/json');res.end(JSON.stringify({submitted}));return;}res.setHeader('content-type','text/html');res.end('Public login fixture
');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r)); +const origin=`https://127.0.0.1:${sink.address().port}`,store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url'); +const p=store.get('policy');p.browser.homeUrl=origin+'/login';p.passwordManager={enabled:true,allowSaving:true,allowAutofill:true,idleLockSeconds:300}; +p.agents={...p.agents,enabled:true,identityMode:'individual',scopes:['sessions','vault:status','vault:list','vault:fill'],allowedHosts:['127.0.0.1','127.0.0.2'],grants:[{id:'vault-a',name:'Vault A',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['sessions','vault:status','vault:list','vault:fill'],allowedHosts:['127.0.0.1'],generation:1},{id:'vault-b',name:'Vault B',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['vault:status','vault:list'],allowedHosts:['127.0.0.1'],generation:1}]}; +p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'vault-fixture'); +const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`; +const reviewer=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-vault-reviewer',role:'admin'})); +const child=spawn(createRequire(import.meta.url)('electron'),['scripts/vault-agent-controls-smoke.cjs'],{windowsHide:true,stdio:'inherit',env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_ORIGIN:origin}});const deadline=setTimeout(()=>child.kill(),180000);const exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(deadline); +for(const server of [management,sink])server.closeAllConnections();await Promise.all([new Promise(r=>management.close(r)),new Promise(r=>sink.close(r))]);store.db.close();await telemetry?.shutdown();rmSync(temporary,{recursive:true,force:true});if(submitted!==0)throw Error('Vault fixture unexpectedly submitted a form');process.exit(exit); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index ac77c7d..e7e6e1d 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -14,7 +14,7 @@ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize for(const file of ['apps/browser/upload-lease.cjs','apps/browser/file-upload-gate.cjs','apps/browser/request-source.cjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged upload binding source is stale: ${file}`); assert.deepEqual(await fs.readFile(path.join(root,'resources/inspector/upload_lease.py')),await fs.readFile('apps/inspector/upload_lease.py'),'Packaged Windows upload lease helper is stale'); assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize('apps/browser/header-inspection.cjs')),await fs.readFile('apps/browser/header-inspection.cjs'),'Packaged outgoing header DLP source is stale'); -for(const file of ['apps/browser/window-controls.cjs','apps/browser/history-snapshot.cjs','apps/browser/human-interaction.cjs','packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`); +for(const file of ['apps/browser/vault-agent-controls.cjs','apps/browser/vault.cjs','apps/browser/window-controls.cjs','apps/browser/history-snapshot.cjs','apps/browser/human-interaction.cjs','packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`); for(const file of ['apps/browser/agent-grants.cjs','apps/browser/policy-assignment.cjs','packages/core/policy-groups.mjs','apps/browser/tab-registry.cjs','apps/browser/agent-adapters.cjs','apps/browser/agent-control.cjs','apps/browser/password-fill.cjs','apps/browser/diagnostics.cjs','packages/core/diagnostics.mjs','packages/core/security.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged device/session policy source is stale: ${file}`); for(const file of ['apps/browser/main.cjs','apps/browser/preload.cjs','apps/browser/site-preload.cjs','apps/browser/clipboard-deny.cjs','apps/browser/clipboard-guard.cjs','apps/browser/clipboard-dom.cjs','apps/browser/page-clipboard-guard.cjs','apps/browser/startup-security.cjs','apps/browser/visual-capture.cjs','apps/browser/renderer.js','apps/browser/index.html','apps/browser/style.css','packages/core/clipboard.mjs']){ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged source is stale: ${file}`); diff --git a/tests/admin-controls-ui.test.mjs b/tests/admin-controls-ui.test.mjs index 15c881d..20e3255 100644 --- a/tests/admin-controls-ui.test.mjs +++ b/tests/admin-controls-ui.test.mjs @@ -12,3 +12,5 @@ import {FileTransfersSchema} from '../packages/core/file-transfer.mjs'; test('download scan settings remain explicit and cannot alter upload policy',()=>{const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{checked:false,disabled:false});return fields.get(id);};const policy={fileTransfers:FileTransfersSchema.parse({upload:{blockUnknown:true},download:{}}),security:{yara:{enabled:true,rules:[],scanOfficeMacros:true}}};const context=vm.createContext({$:get,fill(){},draft:()=>structuredClone(policy),applyOperatorPermissions(){},independentApprovalRequired:true,policy:null,currentPrincipal:null});vm.runInContext(readFileSync(new URL('../apps/server/public/download-scan-admin.js',import.meta.url),'utf8'),context);context.fill(policy);assert.equal(get('download-document-dlp').checked,true);get('download-document-dlp').checked=false;get('yara-office-macros').checked=false;const updated=context.draft();assert.equal(FileTransfersSchema.parse(updated.fileTransfers).download.requireDocumentDlp,false);assert.deepEqual(updated.fileTransfers.upload,policy.fileTransfers.upload);assert.equal(updated.security.yara.scanOfficeMacros,false);context.applyOperatorPermissions({role:'auditor',permissions:['policy.read']});assert.equal(get('yara-office-macros').disabled,true);}); test('new history and window capability controls require explicit opt-in and explain limits',()=>{for(const scope of ['history:list','history:go','window:state','window:control']){const tag=html.match(new RegExp(`]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}assert.ok(html.includes('History read and history navigation are separate capabilities'));assert.ok(html.includes('never grants OS or administrator access'));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');assert.ok(editor.includes('enabled:false'));assert.ok(editor.includes('scopes:[]'));assert.ok(editor.includes('check.disabled=!agentGrantsEditable'));}); + +test('vault agent capabilities start unchecked and describe human-unlocked exact-origin access',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);for(const scope of ['vault:status','vault:list','vault:fill']){assert.ok(scopes.includes(scope));const tag=html.match(new RegExp(`]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}for(const text of ['A person must unlock the local vault','agents cannot unlock it or export passwords',"credential's exact origin"])assert.ok(html.includes(text));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');for(const text of ['human unlock','Agents cannot unlock the vault or export passwords'])assert.ok(editor.includes(text));for(const unsupported of ['vault:unlock','vault:export','vault:mutate'])assert.ok(!scopes.includes(unsupported));}); diff --git a/tests/agent-control.test.mjs b/tests/agent-control.test.mjs index e31c788..00821fb 100644 --- a/tests/agent-control.test.mjs +++ b/tests/agent-control.test.mjs @@ -13,6 +13,14 @@ async function fixture(t, overrides = {}) { return {api, request,calls,events}; } +test('vault actions cannot export credential fields or accept unlock or raw password arguments',async t=>{ + const credentialRef='00000000-0000-4000-8000-000000000001';let fills=0; + const {request,events}=await fixture(t,{actions:{'vault:status':async()=>({exists:true,locked:false,enabled:true,allowAutofill:true,password:'PRIVATE_PASSWORD'}),'vault:list':async()=>[{credentialRef,origin:'https://login.example',username:'PRIVATE_USERNAME',label:'PRIVATE_LABEL',password:'PRIVATE_PASSWORD',id:'PRIVATE_ID'}],'vault:fill':async()=>{fills++;return {completed:true,password:'PRIVATE_PASSWORD'};}}}); + for(const [action,args]of [['vault:status',{}],['vault:list',{sessionId:'session'}],['vault:fill',{sessionId:'session',credentialRef}]]){const response=await request(action,args);assert.equal(response.status,200);assert.equal(JSON.stringify(await response.json()).includes('PRIVATE'),false);} + for(const [action,args]of [['vault:unlock',{master:'PRIVATE_PASSWORD'}],['vault:fill',{sessionId:'session',credentialRef,password:'PRIVATE_PASSWORD'}],['vault:fill',{credentialRef}],['vault:fill',{sessionId:'session',credentialRef:'PRIVATE_ID'}],['vault:list',{master:'PRIVATE_PASSWORD'}]])assert.equal((await request(action,args)).status,400); + assert.equal(fills,1);assert.equal(JSON.stringify(events).includes('PRIVATE'),false);assert.ok(events.some(e=>e.action==='vault:fill'&&e.scope==='vault:fill'&&e.outcome==='completed')); +}); + test('new history and window actions use protected transport, strict arguments and projected outputs',async t=>{ const snapshotId='00000000-0000-4000-8000-000000000001',entryId='00000000-0000-4000-8000-000000000002';let effects=0; const {request,events}=await fixture(t,{actions:{'history:list':async()=>({snapshotId,entries:[{entryId,title:'Public',url:'https://example.test/',active:false,index:0,pageState:'PRIVATE_NATIVE_STATE'}]}),'history:go':async()=>{effects++;return {completed:true,url:'PRIVATE_TARGET'};},'window:state':async()=>({width:1200,height:800,visible:true,focused:true,minimized:false,maximized:false,fullScreen:false,title:'PRIVATE_WINDOW_TITLE'}),'window:control':async()=>{effects++;return {completed:true,state:{title:'PRIVATE_STATE'}};}}}); diff --git a/tests/vault-agent-controls.test.mjs b/tests/vault-agent-controls.test.mjs new file mode 100644 index 0000000..9c21e76 --- /dev/null +++ b/tests/vault-agent-controls.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const {createVaultAgentControls}=createRequire(import.meta.url)('../apps/browser/vault-agent-controls.cjs'); +function fixture(){ + let clock=Date.now(),locked=false,valid=true,url='https://work.example/login',scripts=0,visual=0,generation=1; + const rows=[{id:'71911cdc-a390-4e73-a604-d98d05fd52a5',origin:'https://work.example',username:'private-user',label:'private-label'},{id:'81911cdc-a390-4e73-a604-d98d05fd52a5',origin:'https://other.example',username:'other'}]; + const c={ready:true,token:{},actor:{agentId:'actor-one',generation:1},sessionId:'71911cdc-a390-4e73-a604-d98d05fd52a5',foreground:true,interactive:true,policy:{expiresAt:new Date(clock+100000).toISOString(),agents:{enabled:true,scopes:['vault:status','vault:list','vault:fill'],allowedHosts:['work.example']},passwordManager:{enabled:true,allowAutofill:true}},destinationAllowed:()=>true,webContents:{getURL:()=>url,isDestroyed:()=>false,focus(){},executeJavaScriptInIsolatedWorld:async()=>{scripts++;return {completed:true};}}}; + const vault={accessGeneration:()=>generation,status:()=>({exists:true,locked}),list:()=>rows.map(row=>({...row})),credential:(id,origin)=>{assert.equal(id,rows[0].id);assert.equal(origin,rows[0].origin);return {username:'private-user',password:'private-password'};}}; + const events=[]; + const cb={getContext:()=>c,contextCurrent:()=>valid,getVault:()=>vault,checkVisual:async()=>{visual++;return {decision:'allow'};},inspect:async()=>({decision:'allow',coverage:{complete:true}}),audit:async e=>events.push(e),now:()=>clock}; + return {c,rows,cb,make:()=>createVaultAgentControls(cb),lock:()=>locked=true,rotate:()=>generation++,invalidate:()=>valid=false,expire:()=>clock+=60001,setUrl:u=>url=u,get scripts(){return scripts;},get visual(){return visual;},events}; +} +test('agent vault inventory is scoped, classified and contains opaque references only',async()=>{ + const f=fixture(),a=f.make(),rows=await a.list();assert.equal(rows.length,1);assert.deepEqual(Object.keys(rows[0]).sort(),['credentialRef','origin']);assert.notEqual(rows[0].credentialRef,f.rows[0].id); + assert.deepEqual(await a.status(),{exists:true,locked:false,enabled:true,allowAutofill:true}); + assert.deepEqual(await a.fill({credentialRef:rows[0].credentialRef}),{completed:true});assert.equal(f.scripts,1); + await assert.rejects(a.fill({credentialRef:rows[0].credentialRef}));assert.equal(f.scripts,1); + assert.doesNotMatch(JSON.stringify(f.events),/private|password|credentialRef/); + assert.deepEqual(Object.keys(a).sort(),['clear','fill','list','status']); +}); +test('locked vault and failed/incomplete classifier cannot export inventory',async()=>{ + for(const mode of ['locked','block','incomplete','error','changed']){const f=fixture();if(mode==='locked')f.lock();else f.cb.inspect=async()=>{if(mode==='error')throw Error('private-password');if(mode==='changed')f.rows[0].origin='https://changed.example';return {decision:mode==='block'?'block':'allow',coverage:{complete:mode!=='incomplete'}};};await assert.rejects(f.make().list());} +}); +test('vault references cannot cross identity, origin, policy, expiry, lock or foreground',async()=>{ + for(const mode of ['actor','origin','policy','expire','locked','foreground','scope','token','autofill']){const f=fixture(),a=f.make(),[row]=await a.list();if(mode==='actor')f.c.actor={agentId:'other',generation:1};if(mode==='origin')f.setUrl('https://other.example/login');if(mode==='policy')f.c.policy={...f.c.policy};if(mode==='expire')f.expire();if(mode==='locked')f.lock();if(mode==='foreground')f.c.foreground=false;if(mode==='scope')f.c.policy.agents.scopes=[];if(mode==='token')f.invalidate();if(mode==='autofill')f.c.policy.passwordManager.allowAutofill=false;await assert.rejects(a.fill({credentialRef:row.credentialRef}));assert.equal(f.scripts,0,mode);} +}); +test('visual and durable audit waits recheck revocation and abort before credential effect',async()=>{ + for(const mode of ['visual','audit','abort']){const f=fixture(),controller=new AbortController();if(mode==='visual')f.cb.checkVisual=async()=>{f.c.foreground=false;return {decision:'allow'};};else f.cb.audit=async()=>{if(mode==='abort')controller.abort();else f.lock();};const a=f.make(),[row]=await a.list();await assert.rejects(a.fill({credentialRef:row.credentialRef},{signal:controller.signal}));assert.equal(f.scripts,0);} +}); +test('native failure does not expose credentials and consumes the fill authorization',async()=>{ + const f=fixture();f.c.webContents.executeJavaScriptInIsolatedWorld=async()=>{throw Error('private-password');};const a=f.make(),[row]=await a.list();await assert.rejects(a.fill({credentialRef:row.credentialRef}),{message:'Managed vault operation refused'});await assert.rejects(a.fill({credentialRef:row.credentialRef})); +}); +test('vault generation invalidates references even when the vault is unlocked again',async()=>{ + const f=fixture(),a=f.make(),[row]=await a.list();f.rotate();await assert.rejects(a.fill({credentialRef:row.credentialRef}));assert.equal(f.scripts,0); + const g=fixture();g.cb.inspect=async()=>{g.rotate();return {decision:'allow'};};await assert.rejects(g.make().list()); +}); +test('all callback failures are sanitized at the vault boundary',async()=>{ + const f=fixture();f.cb.inspect=async()=>{throw Error('private-password');};await assert.rejects(f.make().list(),{message:'Managed vault operation refused'}); + f.cb.getVault=()=>{throw Error('private-master');};await assert.rejects(f.make().status(),{message:'Managed vault operation refused'}); +}); +test('background reference cannot be filled in another same-origin native session',async()=>{ + const f=fixture();f.c.foreground=false;const other={...f.c,foreground:true,sessionId:'81911cdc-a390-4e73-a604-d98d05fd52a5',webContents:{...f.c.webContents}}; + f.cb.getContext=id=>id===other.sessionId?other:f.c;let retrieved=0;const vault=f.cb.getVault();vault.credential=()=>{retrieved++;throw Error('Should not retrieve');}; + const a=f.make(),[row]=await a.list({sessionId:f.c.sessionId});await assert.rejects(a.fill({sessionId:other.sessionId,credentialRef:row.credentialRef}));assert.equal(retrieved,0);assert.equal(f.visual,0);assert.equal(f.scripts,0); +}); +test('direct vault arguments reject secret-bearing or arbitrary extra fields',async()=>{ + const f=fixture(),a=f.make();for(const method of ['status','list','fill'])for(const args of [{masterPassword:'secret'},{password:'secret'},{foo:'bar'},[],null])await assert.rejects(a[method](args)); +}); +test('allow verdict with unavailable inspection marker cannot export references',async()=>{ + for(const ruleId of ['uninspectable','size-limit','inspection-incomplete','unavailable','timeout']){const f=fixture();f.cb.inspect=async()=>({decision:'allow',ruleId});await assert.rejects(f.make().list());} +}); diff --git a/tests/vault.test.mjs b/tests/vault.test.mjs index 75a4e71..62b4f82 100644 --- a/tests/vault.test.mjs +++ b/tests/vault.test.mjs @@ -101,3 +101,37 @@ test('background metadata polling cannot postpone the idle lock deadline', t => assert.equal(engine.status().locked, true); assert.throws(() => engine.list()); }); + +test('internal access generation invalidates refs across reopen and same-id credential changes', t => { + const { engine } = fixture(t); t.after(engine.lock); + const initial = engine.accessGeneration(); + engine.create(master); assert.ok(engine.accessGeneration() > initial); + const { id } = engine.upsert({ origin: 'https://company.example', username: 'user', password: 'first-fixture-value' }); + const listed = engine.accessGeneration(); + engine.upsert({ id, origin: 'https://company.example', username: 'user', password: 'second-fixture-value' }); + assert.ok(engine.accessGeneration() > listed); + assert.equal(engine.list()[0].id, id); + const changed = engine.accessGeneration(); + engine.lock(); assert.ok(engine.accessGeneration() > changed); + const locked = engine.accessGeneration(); + engine.unlock(master); assert.ok(engine.accessGeneration() > locked); + assert.equal(engine.list()[0].id, id); + const reopened = engine.accessGeneration(); + engine.delete(id); assert.ok(engine.accessGeneration() > reopened); + assert.equal('accessGeneration' in engine.status(), false); +}); + +test('internal access generation and metadata reads neither renew idle unlock nor invalidate refs', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { engine } = fixture(t); t.after(engine.lock); + engine.create(master); + const generation = engine.accessGeneration(); + for (let elapsed = 0; elapsed < 300_000; elapsed += 5000) { + assert.equal(engine.accessGeneration(), generation); + assert.equal(engine.status().locked, false); + assert.deepEqual(engine.list(), []); + t.mock.timers.tick(5000); + } + assert.equal(engine.status().locked, true); + assert.ok(engine.accessGeneration() > generation); +});