Add scoped human-unlocked vault agent autofill

This commit is contained in:
Karl committed 2026-10-13 19:14:32 +02:00
1 parent 61fcd8336f
commit e8a654fb38
23 files changed
+383 -29

No files matched your search

+2
View File
@@ -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(`<input[^>]*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(`<input[^>]*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));});
+8
View File
@@ -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'}};}}});
+52
View File
@@ -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());}
});
+34
View File
@@ -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);
});