Add scoped native history and window agent controls
This commit is contained in:
1 parent
adf0940e5f
commit
61fcd8336f
27 files changed
+503
-25
No files matched your search
@@ -0,0 +1,46 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createAgentAdapters}=createRequire(import.meta.url)('../apps/browser/agent-adapters.cjs');
|
||||
function fixture(){
|
||||
let url='https://work.example/current',generation=0,history={activeIndex:1,entries:[{url:'https://work.example/old',title:'Old'},{url,title:'Current'}]},calls=0;
|
||||
const policy={version:1,agents:{enabled:true,scopes:['history:list','history:go'],allowedHosts:['work.example'],allowSensitiveRead:true}};
|
||||
const webContents={getURL:()=>url},context=()=>({ready:true,policy,sessionId:'session',foreground:true,token:generation+1,webContents,destinationAllowed:()=>true});
|
||||
const callbacks={getContext:context,getSessions:()=>[],contextCurrent:token=>token===generation+1,navigate:async()=>{},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{},getHistory:()=>history,goHistory:async(index,initial,{historyBinding})=>{assert.equal(JSON.stringify(history),historyBinding);calls++;url=history.entries[index].url;generation++;return {completed:true,context:context()};}};
|
||||
return {callbacks,policy,history,actions:()=>createAgentAdapters(callbacks),calls:()=>calls,invalidate:()=>generation++};
|
||||
}
|
||||
test('history exports bounded metadata and opaque identifiers, navigates exact snapshot once',async()=>{
|
||||
const f=fixture(),a=f.actions(),list=await a['history:list']({});assert.equal(list.entries.length,2);assert.equal(list.entries[0].index,undefined);assert.match(list.snapshotId,/^[a-f0-9-]{36}$/);
|
||||
assert.deepEqual(await a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}),{completed:true});assert.equal(f.calls(),1);
|
||||
await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),1);
|
||||
});
|
||||
test('history masks credentials and DLP without sensitive override, discards native page state',async()=>{
|
||||
const f=fixture();f.history.entries[0]={url:'https://work.example/?access_token=private',title:'password=secret22',pageState:'private form'};f.callbacks.inspect=async()=>({decision:'block',ruleId:'corporate'});
|
||||
const list=await f.actions()['history:list']({});assert.equal(list.entries[0].title,'Protected page');assert.equal(list.entries[0].url,undefined);assert.equal(list.entries[1].url,undefined);assert.ok(!JSON.stringify(list).includes('private'));
|
||||
});
|
||||
test('history inspection errors, incomplete coverage and asynchronous replacement fail closed',async()=>{
|
||||
for(const verdict of [null,{decision:'allow',coverage:{complete:false}},{decision:'block',ruleId:'uninspectable'}]){const f=fixture();f.callbacks.inspect=async()=>verdict;await assert.rejects(f.actions()['history:list']({}),/Inspection/);}
|
||||
for(const mutate of [f=>f.history.entries[0].title='Changed',f=>f.invalidate(),f=>f.policy.agents.scopes=[]]){const f=fixture();f.callbacks.inspect=async()=>{mutate(f);return{decision:'allow'};};await assert.rejects(f.actions()['history:list']({}));}
|
||||
});
|
||||
test('history index replacement, authority revocation and foreign identifiers prevent effects',async()=>{
|
||||
for(const mutate of [f=>f.history.entries[0].url='https://work.example/replaced',f=>f.invalidate(),f=>f.policy.agents.scopes=['history:list']]){const f=fixture(),a=f.actions(),list=await a['history:list']({});mutate(f);await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);}
|
||||
const f=fixture(),a=f.actions(),list=await a['history:list']({});await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:'foreign'}));assert.equal(f.calls(),0);
|
||||
});
|
||||
test('history host restrictions exclude entries and cancellation prevents disclosure',async()=>{
|
||||
const f=fixture();f.history.entries[0].url='https://other.example/';assert.equal((await f.actions()['history:list']({})).entries.length,1);
|
||||
const g=fixture(),controller=new AbortController();g.callbacks.inspect=async()=>{controller.abort();return{decision:'allow'};};await assert.rejects(g.actions()['history:list']({}, {signal:controller.signal}));
|
||||
});
|
||||
test('history grants cannot cross agent identities or navigate masked credentials',async()=>{
|
||||
const f=fixture();let actor={agentId:'a',generation:1};const context=f.callbacks.getContext;f.callbacks.getContext=()=>({...context(),actor});
|
||||
const a=f.actions(),list=await a['history:list']({});actor={agentId:'b',generation:1};await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);
|
||||
const g=fixture();g.history.entries[0].url='https://work.example/?code=private';const b=g.actions(),masked=await b['history:list']({});await assert.rejects(b['history:go']({snapshotId:masked.snapshotId,entryId:masked.entries[0].entryId}));assert.equal(g.calls(),0);
|
||||
});
|
||||
test('encoded query keys and fragment credentials are masked and cannot be navigated',async()=>{
|
||||
for(const suffix of ['?%74oken=private','?%2574oken=private','#refresh_token=private','?sid=private','?client_secret=private']){
|
||||
const f=fixture();f.history.entries[0].url='https://work.example/'+suffix;const a=f.actions(),list=await a['history:list']({});assert.equal(list.entries[0].url,undefined);assert.equal(list.entries[0].title,'Protected page');await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);
|
||||
}
|
||||
});
|
||||
test('native page-state checksum stays private and detects same-title/URL replacement',async()=>{
|
||||
const f=fixture();f.history.nativeBinding='a'.repeat(64);const a=f.actions(),list=await a['history:list']({});assert.ok(!JSON.stringify(list).includes('a'.repeat(64)));f.history.nativeBinding='b'.repeat(64);await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);
|
||||
const g=fixture();g.history.nativeBinding='a'.repeat(64);g.callbacks.inspect=async()=>{g.history.nativeBinding='b'.repeat(64);return{decision:'allow'};};await assert.rejects(g.actions()['history:list']({}));
|
||||
});
|
||||
Reference in new issue
Block a user