Implement managed browser security, individual agents and observability

This commit is contained in:
Karl committed 2026-10-13 16:58:15 +02:00
1 parent ff482ebe23
commit ea448de2d4
172 files changed
+5404 -347

No files matched your search

+132
View File
@@ -172,3 +172,135 @@ test('whole-application policy capabilities are opt-in and unknown scopes are re
assert.deepEqual(AgentsSchema.parse({enabled:true,scopes}).scopes,scopes);
assert.throws(()=>AgentsSchema.parse({enabled:true,scopes:['policy:override']}));
});
test('structured agent inspection classifies field values and rejects policy races',async()=>{
const f=fixture();f.context.policy.agents.scopes.push('inspect','scroll');
f.context.webContents.executeJavaScript=async()=>({controls:[{selector:'#search',role:'input',label:'Search',value:'Public query',text:''}]});
assert.equal((await f.actions.inspect({})).controls[0].value,'Public query');
f.context.webContents.executeJavaScript=async()=>({controls:[{value:'password=hunter22'}]});
await assert.rejects(f.actions.inspect({}),/Sensitive/);
f.context.webContents.executeJavaScript=async()=>{f.setVersion(2);return{controls:[]};};
await assert.rejects(f.actions.inspect({}),/changed/);
});
test('agent scroll is bounded and restricted to an interactive foreground session',async()=>{
const f=fixture();f.context.policy.agents.scopes.push('scroll');
assert.equal((await f.actions.scroll({x:0,y:200})).completed,true);
await assert.rejects(f.actions.scroll({x:0,y:-1}),/Invalid scroll/);
});
test('structured DOM inspection excludes credentials and hidden inputs',async()=>{
const f=fixture();f.context.policy.agents.scopes.push('inspect');
f.context.webContents.executeJavaScript=async code=>{
const element=(id,type,value,hidden=false)=>({id,type,value,tagName:'INPUT',parentElement:null,innerText:'',getAttribute:a=>a==='id'?id:null,closest:()=>null,getClientRects:()=>hidden?[]:[{}]});
const elements=[element('search','text','Public value'),element('password','password','PRIVATE_PASSWORD_SENTINEL'),element('token','text','PRIVATE_TOKEN_SENTINEL'),element('hidden','text','HIDDEN_SENTINEL',true)];
return vm.runInNewContext(code,{location:{href:'https://work.example/'},document:{body:{},querySelectorAll:s=>s.startsWith('#')?elements.filter(e=>'#'+e.id===s):elements},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})});
};
const result=await f.actions.inspect({});assert.equal(result.controls.length,1);assert.equal(result.controls[0].selector,'#search');assert.equal(result.controls[0].value,'Public value');
});
function formFixture(node) {
const f=fixture(); f.context.policy.agents.scopes.push('select','check');
let clicks=0;
class Select {} Object.defineProperty(Select.prototype,'value',{set(value){this.value=value;}});
for(const option of node.options||[]) {option.getAttribute??=()=>null;option.parentElement??=node;}
node.getAttribute ??= ()=>null; node.closest ??= ()=>null; node.getClientRects ??= ()=>[{}]; node.dispatchEvent ??= ()=>{};
node.click ??= ()=>{clicks++;node.checked=!node.checked;};
f.context.webContents.executeJavaScript=async code=>vm.runInNewContext(code,{location:{href:'https://work.example/'},document:{body:{},querySelector:()=>node,querySelectorAll:()=>[node]},getComputedStyle:e=>e.style??{display:'block',visibility:'visible',opacity:'1'},HTMLSelectElement:Select,Event:class {constructor(type){this.type=type;}}});
return {...f,clicks:()=>clicks};
}
test('fixed select chooses a unique enabled existing option and checks hostile handlers',async()=>{
const node={tagName:'SELECT',value:'a',options:[{value:'a'},{value:'b'}]};
const f=formFixture(node);assert.equal((await f.actions.select({selector:'#choice',value:'b'})).completed,true);assert.equal(node.value,'b');
node.options.push({value:'b',getAttribute:()=>null,parentElement:node});await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/ambiguous/);node.options.pop();
node.options[1].disabled=true;await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/unavailable/);node.options[1].disabled=false;
node.options[1].parentElement={disabled:true};await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/unavailable/);node.options[1].parentElement=node;
node.options[1].getAttribute=a=>a==='data-sensitive'?'':null;await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/unavailable/);node.options[1].getAttribute=()=>null;
node.options[1].parentElement={hidden:true,getAttribute:()=>null,parentElement:node};await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/unavailable/);node.options[1].parentElement=node;
await assert.rejects(f.actions.select({selector:'#choice',value:'missing'}),/unavailable/);
node.dispatchEvent=()=>{node.value='a';};await assert.rejects(f.actions.select({selector:'#choice',value:'b'}),/changed/);
});
test('checkbox check is idempotent, rejects radio and verifies hostile toggles',async()=>{
const node={tagName:'INPUT',type:'checkbox',checked:false};const f=formFixture(node);
await f.actions.check({selector:'#choice',checked:true});assert.equal(f.clicks(),1);
await f.actions.check({selector:'#choice',checked:true});assert.equal(f.clicks(),1);
await f.actions.check({selector:'#choice',checked:false});assert.equal(f.clicks(),2);
node.type='radio';await assert.rejects(f.actions.check({selector:'#choice',checked:true}),/cannot be checked/);node.type='checkbox';
node.click=()=>{};await assert.rejects(f.actions.check({selector:'#choice',checked:true}),/changed/);
await assert.rejects(f.actions.check({selector:'#choice',checked:'true'}),/Invalid checked/);
});
test('select and check reject hidden, credential and disabled-ancestor targets',async()=>{
for(const mode of ['select','check'])for(const kind of ['hidden','password','disabled','ancestor','sensitive-empty','sensitive-body']) {
const node={tagName:mode==='select'?'SELECT':'INPUT',type:mode==='check'?'checkbox':'select-one',checked:false,options:[{value:'b'}]};const f=formFixture(node);
if(kind==='sensitive-empty')node.getAttribute=a=>a==='data-sensitive'?'':null;
if(kind==='sensitive-body')node.parentElement={parentElement:null,getAttribute:a=>a==='data-sensitive'?'':null};
if(kind==='hidden')node.getClientRects=()=>[];
if(kind==='password')node.getAttribute=a=>a==='name'?'password':null;
if(kind==='disabled')node.closest=()=>({});
if(kind==='ancestor')node.parentElement={parentElement:null,getAttribute:()=>null,style:{display:'none',visibility:'visible',opacity:'1'}};
await assert.rejects(f.actions[mode]({selector:'#choice',value:'b',checked:true}),/unavailable|sensitive/);
}
});
test('select and check retain native generation and scope checks around execution',async()=>{
for(const mode of ['select','check']) {
const f=fixture();f.context.policy.agents.scopes.push(mode);f.context.webContents.executeJavaScript=async()=>{f.invalidate();return {completed:true};};
await assert.rejects(f.actions[mode]({selector:'#choice',value:'b',checked:true}),/changed/);
const g=fixture();await assert.rejects(g.actions[mode]({selector:'#choice',value:'b',checked:true}),/scope/);
}
});
test('structured inspection returns bounded safe dropdown choices and checkbox state',async()=>{
const f=fixture();f.context.policy.agents.scopes.push('inspect');
const base={parentElement:null,getAttribute:()=>null,closest:()=>null,getClientRects:()=>[{}]};
const select={...base,id:'choice',tagName:'SELECT',value:'one',options:[]};
select.options=[{...base,parentElement:select,value:'one',label:'One'},{...base,parentElement:select,value:'locked',label:'Locked',disabled:true},{...base,parentElement:select,value:'secret',label:'SECRET_SENTINEL',getAttribute:a=>a==='data-sensitive'?'':null}];
const checkbox={...base,id:'accept',tagName:'INPUT',type:'checkbox',checked:true,value:'on'};const elements=[select,checkbox];
f.context.webContents.executeJavaScript=async code=>vm.runInNewContext(code,{location:{href:'https://work.example/'},document:{body:{},querySelectorAll:s=>s.startsWith('#')?elements.filter(e=>'#'+e.id===s):elements},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})});
const result=await f.actions.inspect({});assert.equal(result.controls[0].options.length,2);assert.equal(result.controls[0].options[1].disabled,true);assert.equal(result.controls[1].checked,true);assert.equal(JSON.stringify(result).includes('SECRET_SENTINEL'),false);
});
test('native history refreshes intentional navigation while binding the original tab',async()=>{
for(const action of ['back','forward','reload']) {
let f,calls=0;
f=fixture({history:async(mode,initial,{signal})=>{
calls++;assert.equal(mode,action);assert.equal(initial.sessionId,f.id);assert.equal(signal,undefined);
f.setUrl('https://work.example/history');
return {completed:true,context:{...f.context,sessionId:f.id,foreground:true,policyVersion:1,token:{generation:1,version:1,url:'https://work.example/history'}}};
}});
f.context.policy.agents.scopes.push(action);
assert.deepEqual(await f.actions[action]({}),{completed:true});assert.equal(calls,1);
}
});
test('history actions require explicit scope, native callback and interactive foreground',async()=>{
for(const action of ['back','forward','reload']) {
const f=fixture();await assert.rejects(f.actions[action]({}),/scope/);
f.context.policy.agents.scopes.push(action);await assert.rejects(f.actions[action]({}),/unavailable/);
let called=false;
const callbacks={getContext:()=>({...f.context,sessionId:f.id,foreground:false,token:{}}),getSessions:()=>[],contextCurrent:()=>true,navigate:async()=>{},history:async()=>{called=true;},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{}};
await assert.rejects(createAgentAdapters(callbacks)[action]({}),/unavailable/);assert.equal(called,false);
callbacks.getContext=()=>({...f.context,sessionId:f.id,foreground:true,interactive:false,token:{}});
await assert.rejects(createAgentAdapters(callbacks)[action]({}),/unavailable/);assert.equal(called,false);
}
});
test('history suppresses cancellation, policy races, switched tabs and blocked final destinations',async()=>{
for(const kind of ['abort','policy','identity','session','token','blocked','host','scheme','incomplete','scope']) {
let f;const controller=new AbortController();
f=fixture({history:async()=>{
f.setUrl(kind==='scheme'?'file:///private':'https://work.example/history');
if(kind==='abort')controller.abort();if(kind==='policy')f.setVersion(2);
if(kind==='blocked')f.context.destinationAllowed=()=>false;
if(kind==='host')f.context.policy.agents.allowedHosts=['other.example'];
if(kind==='scope')f.context.policy.agents.scopes=[];
return {completed:kind!=='incomplete',context:{...f.context,sessionId:kind==='session'?'other':f.id,foreground:true,webContents:kind==='identity'?{}:f.context.webContents,policyVersion:kind==='policy'?2:1,token:{generation:kind==='token'?0:1,version:1,url:'https://work.example/history'}}};
}});
f.context.policy.agents.scopes.push('back');
await assert.rejects(f.actions.back({}, {signal:controller.signal}),/changed|unavailable|blocked|scope/);
}
});
test('history can leave a start page but reload never invokes native callback there',async()=>{
let called=0;const f=fixture({history:async()=>{called++;throw Error('Native target unavailable');}});
f.context.policy.agents.scopes.push('back','forward','reload');f.setUrl('about:blank');
await assert.rejects(f.actions.back({}),/Native target/);await assert.rejects(f.actions.forward({}),/Native target/);
await assert.rejects(f.actions.reload({}),/destination blocked/);assert.equal(called,2);
});