Add scoped human-unlocked vault agent autofill
This commit is contained in:
1 parent
61fcd8336f
commit
e8a654fb38
23 files changed
+383
-29
No files matched your search
@@ -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));});
|
||||
@@ -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'}};}}});
|
||||
|
||||
@@ -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());}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user