Files
safetynet/tests/agent-control.test.mjs
T

192 lines
17 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import http from 'node:http';
const {requestAgent,authorization}=createRequire(import.meta.url)('../apps/browser/agent-transport-client.cjs');
const { startAgentControl } = createRequire(import.meta.url)('../apps/browser/agent-control.cjs');
const channel=createRequire(import.meta.url)('../packages/core/policy-channel.cjs');
async function fixture(t, overrides = {}) {
const calls = [], events = [];
const api = await startAgentControl({ authorize: async ({scope}) => ({allowed:true, role:'agent',scopes:[scope]}), actions: {sessions:async()=>[{id:'work',title:'Work',cookies:'secret'}], navigate:async args=>{calls.push(args);return{};}, read:async()=>({text:'sanitized',password:'secret'}),type:async()=>({}),click:async()=>({}),ai:async()=>({text:'answer'})},audit:async event=>events.push(event),...overrides });
t.after(()=>api.close());
const request = (action,args={},headers={})=>requestAgent(api,{action,args},{headers});
return {api, request,calls,events};
}
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'}};}}});
const list=await request('history:list',{sessionId:'session'});assert.equal(list.status,200);const value=await list.json();assert.equal(JSON.stringify(value).includes('PRIVATE'),false);assert.equal(value.result.entries[0].index,undefined);
assert.equal((await request('history:go',{sessionId:'session',snapshotId,entryId})).status,200);
assert.equal((await request('window:state')).status,200);assert.equal((await request('window:control',{command:'maximize'})).status,200);
for(const [action,args]of [['history:list',{}],['history:go',{sessionId:'session',snapshotId,entryId,index:1}],['history:go',{sessionId:'session',snapshotId:'invalid',entryId}],['window:control',{command:'close'}],['window:state',{title:'PRIVATE'}]])assert.equal((await request(action,args)).status,400);
assert.equal(effects,2);assert.ok(events.some(e=>e.action==='history:go'&&e.scope==='history:go'&&e.outcome==='completed'));assert.ok(events.some(e=>e.action==='window:control'&&e.scope==='window:control'&&e.outcome==='completed'));assert.equal(JSON.stringify(events).includes('PRIVATE'),false);
});
test('postquantum channel refuses plaintext, tampering and replays before adapter execution',async t=>{
const {api,calls}=await fixture(t);
const headers=(route,body='{}')=>({'Content-Type':'application/json',Authorization:authorization(api.token,route,body)});
const send=body=>fetch(`${api.address}/v1/actions`,{method:'POST',headers:headers('/v1/actions',JSON.stringify(body)),body:JSON.stringify(body)});
assert.equal((await send({action:'navigate',args:{url:'https://example.com'}})).status,400);
const bundle=await(await fetch(`${api.address}/v1/challenge`,{method:'POST',headers:headers('/v1/challenge'),body:'{}'})).json();
channel.verifyChallenge(bundle,'local-agent',api.edPublic,api.pqPublic);
const context=channel.prepare(bundle);
try{
const payload=channel.response({kind:'agent-request',action:'navigate',args:{url:'https://example.com'}},bundle,context);
const envelope={handshake:context.request,payload};
const wire=JSON.stringify(envelope);
assert.equal(wire.includes('https://example.com'),false);assert.equal(wire.includes('navigate'),false);
const result=await send(envelope);assert.equal(result.status,200);
const response=await result.json();assert.ok(response.encrypted);
assert.deepEqual(channel.finish(response,bundle,context),{kind:'agent-response',status:200,result:{completed:true}});
assert.equal((await send(envelope)).status,400);assert.equal(calls.length,1);
const bundle2=await(await fetch(`${api.address}/v1/challenge`,{method:'POST',headers:headers('/v1/challenge'),body:'{}'})).json();
const ctx2=channel.prepare(bundle2);
try{const changed=channel.response({kind:'agent-request',action:'navigate',args:{url:'https://example.com'}},bundle2,ctx2);changed.encrypted.tag='AAAAAAAAAAAAAAAAAAAAAA';assert.equal((await send({handshake:ctx2.request,payload:changed})).status,400);assert.equal(calls.length,1);}finally{ctx2.responseKey.fill(0);}
}finally{context.responseKey.fill(0);}
});
test('postquantum server identity must match protected pairing pins',async t=>{
const {api}=await fixture(t);
await assert.rejects(requestAgent({...api,pqPublic:api.edPublic},{action:'sessions'}));
});
test('proof of pairing possession is route-bound, non-secret and one-use',async t=>{
const {api}=await fixture(t);const proof=authorization(api.token,'/v1/challenge');
assert.equal(proof.includes(api.token),false);
const send=route=>fetch(`${api.address}${route}`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:proof},body:'{}'});
assert.equal((await send('/v1/actions')).status,401);
assert.equal((await send('/v1/challenge')).status,200);
assert.equal((await send('/v1/challenge')).status,401);
const bound=authorization(api.token,'/v1/challenge','{}');
assert.equal((await fetch(`${api.address}/v1/challenge`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:bound},body:'{"modified":true}'})).status,401);
});
test('bounded transport supports maximum multibyte action text',async t=>{
let text;const {request}=await fixture(t,{actions:{type:async args=>{text=args.text;return{};}}});
const value='界'.repeat(16384);assert.equal((await request('type',{selector:'#message',text:value})).status,200);assert.equal(text,value);
});
test('encrypted response bounds support maximum multibyte sanitized page text',async t=>{
const text='界'.repeat(65536);const {request}=await fixture(t,{actions:{read:async()=>({text})}});
const response=await request('read');assert.equal(response.status,200);assert.equal((await response.json()).result.text,text);
});
test('revocation during completion audit never releases successful content',async t=>{
let capability;const {api,request}=await fixture(t,{audit:async event=>{if(event.outcome==='completed')capability.revoke();}});capability=api;
const response=await request('read');assert.equal(response.status,403);assert.equal(Object.hasOwn(await response.json(),'result'),false);
});
test('future-dated possession proofs remain replay-protected for their entire acceptance window',async t=>{
const {api}=await fixture(t);const original=Date.now,now=original();let proof;
try{Date.now=()=>now+20000;proof=authorization(api.token,'/v1/challenge');Date.now=()=>now;
const send=()=>fetch(`${api.address}/v1/challenge`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:proof},body:'{}'});
assert.equal((await send()).status,200);Date.now=()=>now+35000;assert.equal((await send()).status,401);
}finally{Date.now=original;}
});
test('client rejects plaintext success and tampered encrypted responses',async t=>{
const {api}=await fixture(t);let mode='plaintext';
const proxy=http.createServer(async(req,res)=>{
const chunks=[];for await(const chunk of req)chunks.push(chunk);
const upstream=await fetch(`${api.address}${req.url}`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:authorization(api.token,req.url,Buffer.concat(chunks))},body:Buffer.concat(chunks)});
const value=await upstream.json();
if(req.url==='/v1/actions'){if(mode==='plaintext'){res.end(JSON.stringify({result:[{id:'forged'}]}));return;}value.encrypted.tag='AAAAAAAAAAAAAAAAAAAAAA';}
res.end(JSON.stringify(value));
});
await new Promise(resolve=>proxy.listen(0,'127.0.0.1',resolve));
t.after(()=>new Promise(resolve=>{proxy.closeAllConnections();proxy.close(resolve);}));
const credential={...api,address:`http://127.0.0.1:${proxy.address().port}`};
await assert.rejects(requestAgent(credential,{action:'sessions'}),/Protected agent response required/);
mode='tamper';await assert.rejects(requestAgent(credential,{action:'sessions'}));
});
test('loopback capability authenticates and strips private adapter fields',async t=>{
const {request,calls,events}=await fixture(t);
assert.equal((await request('sessions',{}, {Authorization:'Bearer wrong'})).status,401);
assert.deepEqual(await (await request('sessions')).json(),{result:[{id:'work',title:'Work'}]});
assert.deepEqual(await (await request('read',{sessionId:'work'})).json(),{result:{text:'sanitized'}});
assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,200);
assert.equal(calls.length,1); assert.ok(events.some(e=>e.outcome==='authorized'));
});
test('library organization actions are typed and never project credentials or executable instructions',async t=>{
const {request}=await fixture(t,{actions:{'library:organize-plan':async()=>({proposalId:'proposal-1',plan:{bookmarks:[{id:'b',folder:'Work',url:'https://secret.test'}],tabs:[{id:'t',group:'Research',script:'evil'}]},binding:{credential:'private'},expiresAt:{token:'private'}}),'library:organize-apply':async()=>({completed:true,token:'private'})}});
assert.equal((await request('library:organize-plan',{prompt:'execute JS'})).status,400);
const result=(await(await request('library:organize-plan')).json()).result;
assert.deepEqual(result,{proposalId:'proposal-1',plan:{bookmarks:[{id:'b',folder:'Work'}],tabs:[{id:'t',group:'Research'}]},expiresAt:null});
assert.equal((await request('library:organize-apply',{proposalId:'proposal-1',confirm:'yes'})).status,400);
assert.deepEqual((await(await request('library:organize-apply',{proposalId:'proposal-1',confirm:true})).json()).result,{completed:true});
});
test('omitted session remains omitted and native inventory exposes only approved foreground metadata',async t=>{
let captured;
const {request}=await fixture(t,{actions:{sessions:async()=>[{id:'71911cdc-a390-4e73-a604-d98d05fd52a5',title:'Work',foreground:true,cookies:'secret'}],read:async args=>{captured=args;return{text:'safe'};}}});
assert.equal((await request('read')).status,200);
assert.equal(Object.hasOwn(captured,'sessionId'),false);
assert.deepEqual((await (await request('sessions')).json()).result,[{id:'71911cdc-a390-4e73-a604-d98d05fd52a5',title:'Work',foreground:true}]);
});
test('typed app actions reject arbitrary settings and project only public application metadata',async t=>{
const calls=[];
const {request}=await fixture(t,{actions:{'application:state':async()=>({ready:true,policyVersion:2,activeTabId:'tab',tabCount:2,token:'credential',server:'private'}),'settings:update':async args=>{calls.push(args);return{visible:true,appearance:args.appearance};},'tabs:create':async args=>{calls.push(args);return{completed:true,sessionId:'new-tab',token:'credential'};}}});
assert.deepEqual((await(await request('application:state')).json()).result,{ready:true,policyVersion:2,activeTabId:'tab',tabCount:2});
assert.equal((await request('settings:update',{appearance:{allowDevTools:true}})).status,400);
assert.equal((await request('settings:update',{appearance:{mode:'dark'}})).status,200);
assert.equal((await request('tabs:create',{url:'file:///C:/secret'})).status,400);
assert.equal((await request('tabs:create',{url:'https://user:[email protected]/'})).status,400);
assert.equal((await request('tabs:activate',{})).status,400);
assert.deepEqual((await(await request('tabs:create',{group:'Work',url:'https://example.test/'})).json()).result,{completed:true,sessionId:'new-tab'});
assert.equal(calls.length,2);
});
test('bookmark mutation schema requires exact revision and rejects extra capability arguments',async t=>{
let captured;
const {request}=await fixture(t,{actions:{'bookmarks:mutate':async args=>{captured=args;return{completed:true};}}});
const args={action:'add',expectedRevision:0,policyVersion:1,bookmark:{id:'personal',title:'Intranet',url:'https://intranet.example/',folder:'Work'}};
assert.equal((await request('bookmarks:mutate',args)).status,200);
assert.equal((await request('bookmarks:mutate',{...args,expectedRevision:-1})).status,400);
assert.equal((await request('bookmarks:mutate',{...args,approve:true})).status,400);
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...args.bookmark,managed:true}})).status,400);
const {folder,...withoutFolder}=args.bookmark;
assert.equal((await request('bookmarks:mutate',{...args,bookmark:withoutFolder})).status,200);
assert.equal(captured.bookmark.folder,'');
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:123}})).status,400);
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:null}})).status,400);
assert.equal((await request('bookmarks:mutate',{...args,bookmark:{...withoutFolder,folder:'Work\nSecrets'}})).status,400);
});
test('scope and role are centrally authorized on every action',async t=>{
const {request,calls}=await fixture(t,{authorize:async()=>({allowed:true,role:'agent',scopes:['sessions:list']})});
assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,403);
assert.equal(calls.length,0);
});
test('rejects browser origins, execution and unsafe navigation',async t=>{
const {request}=await fixture(t);
assert.equal((await request('sessions',{}, {Origin:'https://evil.example'})).status,403);
assert.equal((await request('evaluate',{code:'process.exit()'})).status,400);
assert.equal((await request('navigate',{sessionId:'work',url:'file:///C:/secret'})).status,400);
assert.equal((await request('navigate',{sessionId:'work',url:'https://user:[email protected]'})).status,400);
assert.equal((await request('read',{sessionId:'work',cookies:true})).status,400);
});
test('revocation immediately disables capability and policy denial changes live',async t=>{
let allowed=true;
const {api,request}=await fixture(t,{authorize:async({scope})=>({allowed,role:'agent',scopes:[scope]})});
assert.equal((await request('sessions')).status,200); allowed=false;
assert.equal((await request('sessions')).status,403); api.revoke();
assert.equal((await request('sessions')).status,401);
});
test('timeouts abort adapter and rate limit prevents repeated actions',async t=>{
let signal;
const {request}=await fixture(t,{timeoutMs:20,maxRequestsPerMinute:2,actions:{read:async(_,context)=>{signal=context.signal;await new Promise(resolve=>setTimeout(resolve,100));return{text:'late'};}}});
assert.equal((await request('read',{sessionId:'work'})).status,504);assert.equal(signal.aborted,true);
assert.equal((await request('read',{sessionId:'work'})).status,429);
});
test('audit outage fails closed before adapter execution',async t=>{
const {request,calls}=await fixture(t,{audit:async()=>{throw Error('offline');}});
assert.equal((await request('navigate',{sessionId:'work',url:'https://example.com'})).status,500);
assert.equal(calls.length,0);
});
test('trusted diagnostics report known static reasons and suppress arbitrary sensitive messages',async t=>{
const diagnostics=[];
const {request}=await fixture(t,{onError:async value=>diagnostics.push(value),actions:{read:async()=>{throw Error('Company policy blocks content access');},type:async()=>{throw Error('password=do-not-log-this');}}});
assert.equal((await request('read')).status,500);
assert.equal((await request('type',{selector:'#field',text:'public'})).status,500);
assert.equal(diagnostics[0].reason,'Company policy blocks content access');
assert.equal(diagnostics[1].reason,'unclassified-action-failure');
assert.equal(JSON.stringify(diagnostics).includes('do-not-log-this'),false);
});
test('fixed form actions enforce typed scopes and never accept caller code',async t=>{const calls=[];const {request}=await fixture(t,{actions:{select:async args=>{calls.push(args);return {completed:true};},check:async args=>{calls.push(args);return {completed:true};}}});assert.equal((await request('select',{sessionId:'work',selector:'#language',value:''})).status,200);assert.equal((await request('check',{sessionId:'work',selector:'#remember',checked:false})).status,200);for(const checked of ['true',1,null])assert.equal((await request('check',{selector:'#remember',checked})).status,400);assert.equal((await request('select',{selector:'#language',value:'one',script:'alert(1)'})).status,400);assert.equal(calls.length,2);});
test('inspection projects only bounded option and checkbox metadata',async t=>{const {request}=await fixture(t,{actions:{inspect:async()=>({text:'',controls:[{selector:'#language',role:'select',options:[{value:'en',label:'English',disabled:false,secret:'must-not-pass'}]},{selector:'#remember',role:'input',checked:true,cookies:'must-not-pass'}]})}});const r=await request('inspect');assert.equal(r.status,200);const data=await r.json();assert.equal(data.result.controls[0].options[0].value,'en');assert.equal(data.result.controls[1].checked,true);assert.equal(JSON.stringify(data).includes('must-not-pass'),false);});