Implement managed browser security, individual agents and observability
This commit is contained in:
1 parent
ff482ebe23
commit
ea448de2d4
172 files changed
+5404
-347
No files matched your search
@@ -1,14 +1,87 @@
|
||||
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={})=>fetch(`${api.address}/v1/actions`,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${api.token}`,...headers},body:JSON.stringify({action,args})});
|
||||
const request = (action,args={},headers={})=>requestAgent(api,{action,args},{headers});
|
||||
return {api, request,calls,events};
|
||||
}
|
||||
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);
|
||||
@@ -18,6 +91,15 @@ test('loopback capability authenticates and strips private adapter fields',async
|
||||
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'};}}});
|
||||
@@ -76,7 +158,7 @@ test('revocation immediately disables capability and policy denial changes live'
|
||||
});
|
||||
test('timeouts abort adapter and rate limit prevents repeated actions',async t=>{
|
||||
let signal;
|
||||
const {request}=await fixture(t,{timeoutMs:20,maxRequestsPerMinute:1,actions:{read:async(_,context)=>{signal=context.signal;await new Promise(resolve=>setTimeout(resolve,100));return{text:'late'};}}});
|
||||
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);
|
||||
});
|
||||
@@ -94,3 +176,6 @@ test('trusted diagnostics report known static reasons and suppress arbitrary sen
|
||||
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);});
|
||||
Reference in new issue
Block a user