Files
safetynet/tests/agent-adapters.test.mjs
T
2026-10-13 10:34:48 +02:00

175 lines
13 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {createRequire} from 'node:module';
import {inspect as inspectPolicy} from '../packages/core/policy.mjs';
import {AgentsSchema} from '../packages/core/security.mjs';
const {createAgentAdapters}=createRequire(import.meta.url)('../apps/browser/agent-adapters.cjs');
function fixture(overrides={}) {
let url='https://work.example/',version=1,script,generation=0;
const context={ready:true,policy:{version:1,dlp:{rules:[{id:'corporate',kind:'literal'}]},agents:{enabled:true,allowSensitiveRead:false,allowedHosts:[],scopes:['sessions','read','click','type','navigate','ai']}},destinationAllowed:()=>true,webContents:{getURL:()=>url,executeJavaScript:async code=>{script=code;return {text:'Public material',completed:true};}}};
const id='71911cdc-a390-4e73-a604-d98d05fd52a5';
const capture=sessionId=>sessionId!==undefined && sessionId!==id?null:{...context,sessionId:id,foreground:true,policyVersion:version,token:{generation,version,url}};
const callbacks={getContext:capture,getSessions:()=>[{id,title:'Managed session',foreground:true}],contextCurrent:token=>token.generation===generation&&token.version===version&&token.url===url,navigate:async target=>{url=target;generation++;return capture(id);},ai:async()=>({text:'Helpful answer',token:'secret'}),inspect:async()=>({decision:'allow'}),audit:async()=>{},...overrides};
return {actions:createAgentAdapters(callbacks),context,id,setVersion:n=>version=n,setUrl:value=>{url=value;generation++;},invalidate:()=>generation++,getScript:()=>script};
}
test('managed session metadata and outputs never include credentials',async()=>{
const {actions,id}=fixture();
assert.deepEqual(await actions.sessions(),[{id,title:'Managed session',foreground:true}]);
assert.deepEqual(await actions.read({}),{text:'Public material'});
assert.deepEqual(await actions.ai({prompt:'Hello'}),{text:'Helpful answer'});
await assert.rejects(actions.read({sessionId:'other'}),/unavailable|Unknown/);
});
test('policy changes, navigation races and cancellation suppress read results',async()=>{
for(const mutation of ['policy','url','abort']) {
const controller=new AbortController();const f=fixture();
f.context.webContents.executeJavaScript=async()=>{if(mutation==='policy')f.setVersion(2);if(mutation==='url')f.setUrl('https://evil.example/');if(mutation==='abort')controller.abort();return{text:'safe'};};
await assert.rejects(f.actions.read({}, {signal:controller.signal}));
}
});
test('inspection blocks sensitive reads; explicit read approval never overrides secret detection',async()=>{
const f=fixture({inspect:async()=>({decision:'block',ruleId:'corporate'})});
await assert.rejects(f.actions.read({}),/Company policy/);
f.context.policy.agents.allowSensitiveRead=true;
assert.equal((await f.actions.read({})).text,'Public material');
f.context.webContents.executeJavaScript=async()=>({text:'password=hunter22'});
await assert.rejects(f.actions.read({}),/Sensitive/);
});
test('fixed script excludes form content, hidden text and attributes',async()=>{
const f=fixture();await f.actions.read({});
const element=(excluded=false,hidden=false)=>({closest:selector=>selector.includes('input,textarea')&&excluded?{}:null,getAttribute:()=>null,parentElement:null,getClientRects:()=>hidden?[]:[{}]});
const nodes=[{parentElement:element(),textContent:'Visible company text'},{parentElement:element(true),textContent:'Form secret'},{parentElement:element(false,true),textContent:'Hidden secret'}];
let index=0;
const result=vm.runInNewContext(f.getScript(),{location:{href:'https://work.example/'},document:{body:{},createTreeWalker:()=>({nextNode:()=>nodes[index++]})},NodeFilter:{SHOW_TEXT:4},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})});
assert.equal(result.text,'Visible company text');
});
test('typing script rejects password targets and treats text as data',async()=>{
const f=fixture();await f.actions.type({selector:'#prompt',text:'";process.exit();//'});
const node={type:'password',tagName:'INPUT',parentElement:null,getAttribute:()=>null,closest:()=>null,getClientRects:()=>[{}]};
assert.throws(()=>vm.runInNewContext(f.getScript(),{location:{href:'https://work.example/'},document:{body:{},querySelector:()=>node},getComputedStyle:()=>({display:'block',visibility:'visible',opacity:'1'})}),/sensitive/);
await assert.rejects(f.actions.type({selector:'input:has(*)',text:'x'}),/Invalid selector/);
});
test('navigate permits managed destination change but refuses non-web schemes',async()=>{
const f=fixture();assert.deepEqual(await f.actions.navigate({url:'https://allowed.example/'}),{completed:true});
await assert.rejects(f.actions.navigate({url:'file:///secret'}),/Destination/);
});
test('encoded built-in secrets and scanner failures never permit sensitive-read override',async()=>{
for (const verdict of [{decision:'block',ruleId:'api-secret'},{decision:'block',ruleId:'uninspectable'},{decision:'block',ruleId:'size-limit'},{decision:'block',ruleId:'corporate',engine:'yara'},{decision:'block',ruleId:'corporate',malware:{decision:'block'}},{decision:'block',ruleId:'corporate',coverage:{complete:false}},{decision:'block',ruleId:'unknown'}]) {
const f=fixture({inspect:async()=>verdict});f.context.policy.agents.allowSensitiveRead=true;
f.context.webContents.executeJavaScript=async()=>({text:'sk%2D'+'a'.repeat(25)});
await assert.rejects(f.actions.read({}),/Company policy/);
}
});
test('real core percent-decoded credential verdict is preserved',async()=>{
const policy={version:1,dlp:{maxBytes:10000,rules:[]},security:{content:{blockedPhrases:[]}}};
const f=fixture({inspect:async text=>inspectPolicy(Buffer.from(text),policy)});
f.context.policy.agents.allowSensitiveRead=true;
const text='sk%2D'+'a'.repeat(25);f.context.webContents.executeJavaScript=async()=>({text});
assert.equal(inspectPolicy(Buffer.from(text),policy).ruleId,'api-secret');
await assert.rejects(f.actions.read({}),/Company policy/);
});
test('scopes and agent hosts rechecked before and after asynchronous operations',async()=>{
const f=fixture();f.context.policy.agents.allowedHosts=['work.example'];
f.setUrl('https://other.example/');await assert.rejects(f.actions.read({}),/Agent destination/);
f.setUrl('https://work.example/');f.context.policy.agents.scopes=['sessions'];
await assert.rejects(f.actions.read({}),/Agent scope/);
f.context.policy.agents.scopes=['read'];
f.context.webContents.executeJavaScript=async()=>{f.context.policy.agents.scopes=[];return{text:'safe'};};
await assert.rejects(f.actions.read({}),/Agent scope/);
});
test('fixed DOM script rejects document replacement before any click side effect',async()=>{
const f=fixture();await f.actions.click({selector:'#submit'});let selected=false;
assert.throws(()=>vm.runInNewContext(f.getScript(),{location:{href:'https://other.example/'},document:{querySelector:()=>{selected=true;}}}),/Document changed/);
assert.equal(selected,false);
});
test('same-URL reload, close and switch-away-and-back invalidate captured reads and AI',async()=>{
for(const action of ['read','ai']) {
for(const race of ['reload','close','switch']) {
const f=fixture();
const mutate=()=>{f.invalidate();if(race==='close')f.context.webContents.isDestroyed=()=>true;};
if(action==='read')f.context.webContents.executeJavaScript=async()=>{mutate();return{text:'Public material'};};
else {
const g=fixture({ai:async()=>{g.invalidate();if(race==='close')g.context.webContents.isDestroyed=()=>true;return{text:'Public material'};}});
await assert.rejects(g.actions.ai({prompt:'Hello'}),/changed|unavailable/);continue;
}
await assert.rejects(f.actions.read({}),/changed|unavailable/);
}
}
});
test('navigation cannot complete against another native identity or changed policy',async()=>{
for(const kind of ['identity','policy']) {
const f=fixture({navigate:async target=>{
f.setUrl(target);
return {...f.context,sessionId:f.id,token:{},policyVersion:kind==='policy'?2:1,webContents:kind==='identity'?{}:f.context.webContents};
}});
await assert.rejects(f.actions.navigate({url:'https://work.example/next'}),/changed/);
}
});
test('inspection itself cannot outlive the captured tab generation',async()=>{
const f=fixture({inspect:async()=>{f.invalidate();return{decision:'allow'};}});
await assert.rejects(f.actions.read({sessionId:f.id}),/changed/);
});
test('session inventory and navigation work on the start page',async()=>{
const f=fixture();f.setUrl('about:blank');
assert.equal((await f.actions.sessions())[0].id,f.id);
assert.deepEqual(await f.actions.navigate({url:'https://work.example/'}),{completed:true});
f.context.policy.agents.scopes=[];
await assert.rejects(f.actions.sessions(),/unavailable/);
});
test('an omitted ID captures foreground once instead of following later foreground changes',async()=>{
const f=fixture();const calls=[];let foreground=f.id;
const actions=createAgentAdapters({
getContext:id=>{calls.push(id);return {ready:true,sessionId:id??foreground,foreground:true,token:{},policyVersion:1,policy:f.context.policy,webContents:f.context.webContents,destinationAllowed:()=>true};},
getSessions:()=>[],contextCurrent:()=>true,navigate:async()=>{},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{}
});
f.context.webContents.executeJavaScript=async()=>{foreground='8dc41a3d-c46e-4c35-ae4a-98cfcb3a3553';return{text:'Public material'};};
await actions.read({});
assert.deepEqual(calls,[undefined,f.id,f.id]);
});
test('background interactions fail before page code runs',async()=>{
const f=fixture();let executed=false;
const actions=createAgentAdapters({getContext:()=>({...f.context,sessionId:f.id,token:{},foreground:false}),getSessions:()=>[],contextCurrent:()=>true,navigate:async()=>{},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{}});
f.context.webContents.executeJavaScript=async()=>{executed=true;return{completed:true};};
await assert.rejects(actions.click({selector:'#submit'}),/unavailable/);
await assert.rejects(actions.type({selector:'#prompt',text:'test'}),/unavailable/);
assert.equal(executed,false);
});
test('app controls require explicit new scopes and preserve typed trusted callback arguments',async()=>{
let called=0;
const f=fixture({appControls:{'settings:update':async args=>{called++;assert.deepEqual(args,{appearance:{mode:'dark'}});return{visible:true,appearance:args.appearance};}}});
await assert.rejects(f.actions['settings:update']({appearance:{mode:'dark'}}),/scope/);
assert.equal(called,0);f.context.policy.agents.scopes.push('settings:update');f.setUrl('about:blank');
assert.equal((await f.actions['settings:update']({appearance:{mode:'dark'}})).appearance.mode,'dark');
assert.equal(called,1);
});
test('app result is withheld after policy change and bookmark metadata must pass DLP',async()=>{
const f=fixture({appControls:{'settings:get':async()=>{f.setVersion(2);return{visible:true};}}});
f.context.policy.agents.scopes.push('settings:get');await assert.rejects(f.actions['settings:get']({}),/changed/);
const g=fixture({appControls:{'bookmarks:list':async()=>({managed:[{title:'password=hunter22',url:'https://example.test/',folder:''}],personal:{items:[]}})}});
g.context.policy.agents.scopes.push('bookmarks:list');await assert.rejects(g.actions['bookmarks:list']({}),/Sensitive/);
const h=fixture({inspect:async()=>({decision:'block'}),appControls:{'bookmarks:list':async()=>({managed:[{title:'Confidential program',url:'https://example.test/',folder:''}],personal:{items:[]}})}});
h.context.policy.agents.scopes.push('bookmarks:list');await assert.rejects(h.actions['bookmarks:list']({}),/Company policy/);
});
test('agent tab creation cannot bypass approved destination hosts',async()=>{
let called=false;const f=fixture({appControls:{'tabs:create':async()=>{called=true;return{completed:true};}}});
f.context.policy.agents.scopes.push('tabs:create');f.context.policy.agents.allowedHosts=['work.example'];
await assert.rejects(f.actions['tabs:create']({url:'https://other.example/'}),/destination/);
assert.equal(called,false);
});
test('whole-application policy capabilities are opt-in and unknown scopes are rejected',()=>{
assert.deepEqual(AgentsSchema.parse({enabled:true}).scopes,['sessions']);
const scopes=['application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group'];
assert.deepEqual(AgentsSchema.parse({enabled:true,scopes}).scopes,scopes);
assert.throws(()=>AgentsSchema.parse({enabled:true,scopes:['policy:override']}));
});