Add policy-bound encrypted agent session restoration and audit traces

This commit is contained in:
Karl committed 2026-10-13 19:41:27 +02:00
1 parent e8a654fb38
commit b991ee9da6
27 files changed
+405 -24

No files matched your search

+5 -5
View File
@@ -8,12 +8,12 @@ async function capture(file,section){await evaluate(`document.getElementById('${
app.whenReady().then(async()=>{
window=new BrowserWindow({show:false,width:1500,height:1100,webPreferences:{nodeIntegration:false,contextIsolation:true,sandbox:true,offscreen:true,backgroundThrottling:false}});
const proofTraceIds=[],requestsById=new Map();session.defaultSession.webRequest.onCompleted({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},details=>{const row=requestsById.get(details.id);if(row){row.outcome='completed';row.statusCode=details.statusCode;}});session.defaultSession.webRequest.onErrorOccurred({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},details=>{const row=requestsById.get(details.id);if(row)row.outcome='failed';});session.defaultSession.webRequest.onBeforeSendHeaders({urls:[process.env.SAFETYNET_ADMIN_SMOKE_URL+'/api/*']},(details,callback)=>{const crypto=require('node:crypto'),traceId=crypto.randomBytes(16).toString('hex'),spanId=crypto.randomBytes(8).toString('hex');const path=new URL(details.url).pathname,route=path==='/api/admin/policy'?'/api/admin/policy':path==='/api/admin/policy/restore'?'/api/admin/policy/restore':/^\/api\/admin\/policy-reviews\/[^/]+\/approve$/.test(path)?'/api/admin/policy-reviews/:id/approve':/^\/api\/admin\/policy-history\/[^/]+\/restore$/.test(path)?'/api/admin/policy-history/:id/restore':'other-api';const row={traceId,parentSpanId:spanId,outcome:'pending',method:details.method,route};proofTraceIds.push(row);requestsById.set(details.id,row);callback({requestHeaders:{...details.requestHeaders,traceparent:'00-'+traceId+'-'+spanId+'-01'}});});
await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');for(const scope of ['history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill'])assert.deepEqual(await evaluate(`(()=>{const field=document.getElementById('agent-scope-${scope}');return {present:!!field,checked:field.checked,disabled:field.disabled};})()`),{present:true,checked:false,disabled:false});await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;for(const scope of ['vault:status','vault:list','vault:fill'])document.getElementById('agent-scope-'+scope).checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`);
assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor .checks input')).every(x=>!x.checked)`),true);assert.equal(await evaluate(`document.getElementById('agent-vault-help').textContent.includes('A person must unlock')&&document.getElementById('agent-vault-help').textContent.includes('cannot unlock it or export passwords')`),true);assert.equal(await evaluate(`document.getElementById('agent-grants-editor').textContent.includes('human unlock')`),true);
await evaluate(`const box=document.querySelector('#agent-grants-editor>fieldset'),fields=box.querySelectorAll('.grid input,.grid textarea');fields[0].value='research-agent';fields[0].dispatchEvent(new Event('input'));fields[1].value='Research Agent';fields[1].dispatchEvent(new Event('input'));fields[2].checked=true;fields[2].dispatchEvent(new Event('change'));fields[3].value='2099-12-31T23:59:59Z';fields[3].dispatchEvent(new Event('input'));fields[4].value='*.company.example';fields[4].dispatchEvent(new Event('input'));for(const label of box.querySelectorAll('.checks label'))if(['sessions','read','vault:status','vault:list','vault:fill'].includes(label.textContent)){const input=label.querySelector('input');input.checked=true;input.dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);
await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');for(const scope of ['history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'])assert.deepEqual(await evaluate(`(()=>{const field=document.getElementById('agent-scope-${scope}');return {present:!!field,checked:field.checked,disabled:field.disabled};})()`),{present:true,checked:false,disabled:false});await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').checked=true;for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'])document.getElementById('agent-scope-'+scope).checked=true;document.getElementById('agent-hosts').value='*.company.example';document.getElementById('agent-identity-mode').value='individual';document.getElementById('agent-identity-mode').dispatchEvent(new Event('change'));document.getElementById('agent-grant-add').click();`);
assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor .checks input')).every(x=>!x.checked)`),true);assert.equal(await evaluate(`document.getElementById('agent-vault-help').textContent.includes('A person must unlock')&&document.getElementById('agent-vault-help').textContent.includes('cannot unlock it or export passwords')`),true);assert.equal(await evaluate(`document.getElementById('agent-grants-editor').textContent.includes('human unlock')`),true);assert.equal(await evaluate(`document.getElementById('agent-sessions-help').textContent.includes('no passwords, form values or page state')&&document.getElementById('agent-sessions-help').textContent.includes('never restores revoked agent authority')`),true);
await evaluate(`const box=document.querySelector('#agent-grants-editor>fieldset'),fields=box.querySelectorAll('.grid input,.grid textarea');fields[0].value='research-agent';fields[0].dispatchEvent(new Event('input'));fields[1].value='Research Agent';fields[1].dispatchEvent(new Event('input'));fields[2].checked=true;fields[2].dispatchEvent(new Event('change'));fields[3].value='2099-12-31T23:59:59Z';fields[3].dispatchEvent(new Event('input'));fields[4].value='*.company.example';fields[4].dispatchEvent(new Event('input'));for(const label of box.querySelectorAll('.checks label'))if(['sessions','read','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'].includes(label.textContent)){const input=label.querySelector('input');input.checked=true;input.dispatchEvent(new Event('change'));}document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v1');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);
assert.equal(await evaluate(`fetch('/api/admin/policy-reviews/'+selectedIndependentReview.id+'/approve',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({payloadHash:selectedIndependentReview.payloadHash,expectedVersion:selectedIndependentReview.expectedVersion,approve:true})}).then(r=>r.status)`),403);
await identity('grant-reviewer');await selectReview();const diff=await evaluate(`document.getElementById('independent-review-diff').textContent`);for(const text of ['/agents/grants','research-agent','2099','company.example','sessions','read','generation','vault:status','vault:list','vault:fill'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2);
await identity('grant-auditor');for(const scope of ['vault:status','vault:list','vault:fill'])assert.equal(await evaluate(`document.getElementById('agent-scope-${scope}').disabled`),true);assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true);
await identity('grant-reviewer');await selectReview();const diff=await evaluate(`document.getElementById('independent-review-diff').textContent`);for(const text of ['/agents/grants','research-agent','2099','company.example','sessions','read','generation','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2);
await identity('grant-auditor');for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'])assert.equal(await evaluate(`document.getElementById('agent-scope-${scope}').disabled`),true);assert.equal(await evaluate(`document.getElementById('agent-grant-add').disabled&&document.getElementById('agent-identity-mode').disabled`),true);assert.equal(await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor input, #agent-grants-editor textarea, #agent-grants-editor button')).every(x=>x.disabled)`),true);
await identity('grant-author');assert.equal(await evaluate(`document.querySelector('#agent-grants-editor .grid input').readOnly`),true);await evaluate(`Array.from(document.querySelectorAll('#agent-grants-editor button')).find(x=>x.textContent==='Invalidate previous credentials').click();document.getElementById('publish').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v2');await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('"generation":2'));await approve(3);
await identity('grant-author');await evaluate(`document.getElementById('brand-name').value='Unpublished preserved draft';document.getElementById('history-load').click()`);await waitFor(`document.querySelectorAll('#history-rows tr').length===3`);await evaluate(`document.querySelectorAll('#history-rows button')[1].click()`);await waitFor(`!document.getElementById('history-review').hidden`);assert.equal(await evaluate(`document.getElementById('brand-name').value`),'Unpublished preserved draft');assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');assert.equal(await evaluate(`document.getElementById('history-restore').disabled`),true);const history=await evaluate(`document.getElementById('history-diff-list').textContent`);assert.ok(history.includes('/agents/grants'));assert.ok(history.includes('generation'));assert.ok((await evaluate(`document.getElementById('history-review-summary').textContent`)).includes('does not restore invalidated credentials'));await capture('artifacts/agent-grants-history-admin.png','history-review');await evaluate(`document.getElementById('history-approve').checked=true;document.getElementById('history-approve').dispatchEvent(new Event('change'));document.getElementById('history-restore').click()`);await waitFor(`document.getElementById('status').textContent.includes('prepared for independent approval')`);assert.equal(await evaluate(`document.getElementById('policy-version').textContent`),'v3');await selectReview();assert.equal(await evaluate(`document.getElementById('independent-review-approve').disabled`),true);await identity('grant-reviewer');await selectReview();assert.ok((await evaluate(`document.getElementById('independent-review-diff').textContent`)).includes('research-agent'));await approve(4);
for(let n=0;n<100&&proofTraceIds.some(row=>row.outcome==='pending');n++)await new Promise(r=>setTimeout(r,50));assert.equal(proofTraceIds.some(row=>row.outcome==='pending'),false,'API requests must settle before telemetry shutdown');
+2 -2
View File
@@ -16,6 +16,6 @@ try {
const config={publicUrl:'http://127.0.0.1',secure:false,requireIndependentPolicyApproval:true};server=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));config.publicUrl=`http://127.0.0.1:${server.address().port}`;
const env={...process.env,SAFETYNET_ADMIN_SMOKE_PROFILE:join(directory,'browser'),SAFETYNET_ADMIN_SMOKE_URL:config.publicUrl};delete env.ELECTRON_RUN_AS_NODE;
child=spawn(require('electron'),['scripts/agent-grants-history-admin-smoke.cjs'],{env,windowsHide:true,stdio:['ignore','pipe','pipe']});child.stdout.on('data',d=>{if(String(d).includes('GRANT_ADMIN_OK'))console.log('Grant admin actual UI passed');});child.stderr.on('data',d=>{if(String(d).includes('GRANT_ADMIN_FAILED'))console.error(String(d).trim());});const timer=setTimeout(()=>child.kill(),60000),code=await new Promise(r=>{child.once('exit',c=>r(c??1));child.once('error',()=>r(1));});clearTimeout(timer);assert.equal(code,0);
const p=verifyPolicy(store.envelope(),store.publicKey());assert.equal(p.version,4);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual');for(const scope of ['vault:status','vault:list','vault:fill']){assert.ok(p.agents.scopes.includes(scope));assert.ok(p.agents.grants[0].scopes.includes(scope));}
const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[],failedAttempts=traces.filter(item=>item.outcome==='failed');assert.ok(traces.some(item=>item.outcome==='completed'),'Completed API requests required');assert.equal(traces.some(item=>item.outcome!=='completed'&&['POST','PUT','PATCH','DELETE'].includes(item.method)),false,'Every policy mutation must complete and export a correlated span');for(const item of traces.filter(item=>item.outcome==='completed')){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);assert.equal(r.ok,true,'Completed API request trace must be available');if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,vaultScopesDefaultOffAndReviewed:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified');
const p=verifyPolicy(store.envelope(),store.publicKey());assert.equal(p.version,4);assert.equal(p.agents.grants[0].generation,1);assert.equal(p.agents.identityMode,'individual');for(const scope of ['vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore']){assert.ok(p.agents.scopes.includes(scope));assert.ok(p.agents.grants[0].scopes.includes(scope));}
const traces=JSON.parse(readFileSync('artifacts/agent-grants-admin-request-trace-ids.json','utf8'));await sdk.shutdown();await new Promise(r=>setTimeout(r,100));const exported=[],failedAttempts=traces.filter(item=>item.outcome==='failed');assert.ok(traces.some(item=>item.outcome==='completed'),'Completed API requests required');assert.equal(traces.some(item=>item.outcome!=='completed'&&['POST','PUT','PATCH','DELETE'].includes(item.method)),false,'Every policy mutation must complete and export a correlated span');for(const item of traces.filter(item=>item.outcome==='completed')){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);assert.equal(r.ok,true,'Completed API request trace must be available');if(r.ok){const trace=await r.json();assert.ok(trace.spans.some(s=>s.name==='server.request'&&s.parentSpanId===item.parentSpanId&&s.durationMs>=0));exported.push({traceId:item.traceId,parentSpanId:item.parentSpanId,trace});}}assert.ok(exported.length,'Actual request traces required');mkdirSync('artifacts',{recursive:true});writeFileSync('artifacts/agent-grants-history-admin-traces.json',JSON.stringify(exported,null,2));writeFileSync('artifacts/agent-grants-history-admin-proof.json',JSON.stringify({actualUi:true,vaultScopesDefaultOffAndReviewed:true,sessionScopesDefaultOffAndReviewed:true,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified');
} finally {child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-agent-grant-admin-'))throw Error('Unsafe cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});}
+29
View File
@@ -0,0 +1,29 @@
'use strict';
const {app,BrowserWindow,session,safeStorage}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),{X509Certificate}=require('node:crypto');
app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs');
const wait=ms=>new Promise(r=>setTimeout(r,ms)),base=process.env.SAFETYNET_SMOKE_SERVER,origin=process.env.SAFETYNET_SMOKE_ORIGIN,headers={authorization:`Bearer ${process.env.SAFETYNET_SMOKE_ADMIN}`,'content-type':'application/json'};
async function until(fn){for(let i=0;i<200;i++){const r=await fn();if(r)return r;await wait(100);}throw Error('Vault native fixture timed out');}
const overview=async()=> (await fetch(base+'/api/admin/overview',{headers})).json();
app.whenReady().then(async()=>{const pages=[];let stage='startup';try{
const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});
const fingerprint=new X509Certificate(fs.readFileSync(path.join(__dirname,'../tests/fixtures/icap/cert.pem'))).fingerprint256;
// Only the exact synthetic certificate on loopback is trusted, in this isolated fixture partition.
session.fromPartition('persist:safetynet-managed').setCertificateVerifyProc((req,callback)=>callback(req.hostname==='127.0.0.1'&&new X509Certificate(req.certificate.data).fingerprint256===fingerprint?0:-2));
const ui=async(method,value)=>{const result=await win.webContents.executeJavaScript(`window.safetynet.${method}(${value===undefined?'':JSON.stringify(value)})`);assert.equal(result.ok,true,method);return result.data;};
stage='enrollment';if(process.env.SAFETYNET_SMOKE_PHASE==='save')await ui('enroll',{server:base,code:process.env.SAFETYNET_SMOKE_CODE});else await until(async()=> (await ui('state')).ready);const page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;pages.push(page);await until(()=>!page.isLoading()&&page.getURL().startsWith(origin));
const a=await ui('agentConnection','session-a'),b=await ui('agentConnection','session-b'),requestFile=path.join(app.getPath('userData'),'session-request.json');
async function call(connection,action,args={}){fs.writeFileSync(requestFile,JSON.stringify({action,args}));const child=require('node:child_process').spawn(process.execPath,['scripts/agent-client.cjs','--connection',connection.credentialPath,'--request',requestFile],{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='';child.stdout.on('data',v=>output+=v);child.stderr.on('data',()=>{});const exit=await new Promise(r=>{child.once('exit',r);child.once('error',()=>r(1));});return {exit,result:exit===0?JSON.parse(output).result:undefined};}
const inventory=async()=>{const r=await call(a,'sessions');assert.equal(r.exit,0);return r.result;};
if(process.env.SAFETYNET_SMOKE_PHASE==='save'){
stage='save tabs';const first=(await inventory())[0].id;await ui('groupTab',{id:first,group:'Research'});await ui('createTab',{url:origin+'/reference?view=public#details',group:'Research'});await until(()=>win.contentView.children.filter(v=>v.webContents!==win.webContents).every(v=>!v.webContents.isLoading()));for(const v of win.contentView.children.filter(v=>v.webContents!==win.webContents))await v.webContents.executeJavaScript("document.querySelector('input[name=private]').value='PRIVATE_EDITED_FORM';document.querySelector('input[type=password]').value='PRIVATE_EDITED_PASSWORD';history.replaceState({pageState:'PRIVATE_PAGESTATE'},'')");const ids=(await inventory()).map(row=>row.id);assert.equal(ids.length,2);const saved=await call(a,'sessions:save',{name:'Research collection',sessionIds:ids});assert.equal(saved.exit,0);const listed=await call(a,'sessions:list');assert.equal(listed.exit,0);assert.equal(listed.result.collections[0].tabs.length,2);assert.ok(listed.result.collections[0].tabs.every(t=>t.group==='Research'));assert.ok(listed.result.collections[0].tabs.some(t=>t.url.includes('?q=public#section')));const raw=fs.readFileSync(path.join(app.getPath('userData'),'managed-sessions.bin'));for(const text of ['Research collection',origin,'PRIVATE_FORM_SENTINEL','PRIVATE_PASSWORD_SENTINEL','pageState'])assert.equal(raw.includes(Buffer.from(text)),false);const plain=Buffer.from(safeStorage.decryptString(raw),'base64');try{const parsed=JSON.parse(plain.toString());for(const row of parsed.collections)for(const tab of row.collection.tabs){assert.deepEqual(Object.keys(tab).sort(),['group','title','url']);for(const text of ['PRIVATE_EDITED_FORM','PRIVATE_EDITED_PASSWORD','PRIVATE_PAGESTATE'])assert.ok(!plain.includes(Buffer.from(text)));}}finally{plain.fill(0);}await wait(1500);fs.writeFileSync(path.join(app.getPath('userData'),'session-fixture-id.json'),JSON.stringify({id:saved.result.id}));for(const p of pages)require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p);app.exit(0);return;}
stage='restart inventory';const id=JSON.parse(fs.readFileSync(path.join(app.getPath('userData'),'session-fixture-id.json'))).id;let listed=await call(a,'sessions:list');assert.equal(listed.exit,0);assert.equal(listed.result.collections[0].id,id);for(const text of ['PRIVATE_FORM_SENTINEL','PRIVATE_PASSWORD_SENTINEL','pageState'])assert.ok(!JSON.stringify(listed.result).includes(text));assert.equal((await call(b,'sessions:list')).result.collections.length,0);
stage='cross actor';let plan=await call(a,'sessions:restore',{id});assert.equal(plan.exit,0);assert.equal((await call(b,'sessions:restore',{planId:plan.result.planId})).exit,1); // attempted consumption is scoped to the authenticated actor
plan=await call(a,'sessions:restore',{id});assert.equal(plan.exit,0);const originals=await inventory();stage='restore actual tabs';const restored=await call(a,'sessions:restore',{planId:plan.result.planId});assert.equal(restored.exit,0);assert.equal(restored.result.sessionIds.length,2);await until(()=>win.contentView.children.filter(v=>v.webContents!==win.webContents).every(v=>!v.webContents.isLoading()));let now=await inventory();assert.equal(now.length,originals.length+2);for(const row of originals)assert.ok(now.some(x=>x.id===row.id));assert.ok((await ui('state')).tabs.filter(t=>restored.result.sessionIds.includes(t.id)).every(t=>t.group==='Research'));for(const tab of win.contentView.children.filter(v=>v.webContents!==win.webContents).slice(-2)){assert.equal(await tab.webContents.executeJavaScript('document.querySelector("input[name=private]").value'), 'PRIVATE_FORM_SENTINEL');assert.equal(await tab.webContents.executeJavaScript('document.querySelector("input[type=password]").value'), 'PRIVATE_PASSWORD_SENTINEL');}assert.equal((await call(a,'sessions:restore',{planId:plan.result.planId})).exit,1);
stage='stale revision';plan=await call(a,'sessions:restore',{id});await call(a,'sessions:save',{name:'Another collection',sessionIds:[now[0].id]});assert.equal((await call(a,'sessions:restore',{planId:plan.result.planId})).exit,1);
stage='activation race source';const baseIds=(await inventory()).map(r=>r.id);await ui('createTab',{url:origin+'/delayed?delay=public',group:'Race'});await until(()=>win.contentView.children.filter(v=>v.webContents!==win.webContents).every(v=>!v.webContents.isLoading()));const delayedId=(await inventory()).find(r=>!baseIds.includes(r.id)).id;await ui('createTab',{url:origin+'/second-race',group:'Race'});await until(()=>win.contentView.children.filter(v=>v.webContents!==win.webContents).every(v=>!v.webContents.isLoading()));const secondId=(await inventory()).find(r=>!baseIds.includes(r.id)&&r.id!==delayedId).id;const raceSaved=await call(a,'sessions:save',{name:'Race collection',sessionIds:[delayedId,secondId]});assert.equal(raceSaved.exit,0);await ui('closeTab',secondId);await ui('closeTab',delayedId);await ui('activateTab',baseIds[0]);const raceOriginals=await inventory(),raceContents=win.contentView.children.filter(v=>v.webContents!==win.webContents).map(v=>v.webContents);const raceCounts=async()=> (await page.executeJavaScript("fetch('/count').then(r=>r.json())")).paths;const raceBefore=await raceCounts();const racePlan=await call(a,'sessions:restore',{id:raceSaved.result.id});assert.equal(racePlan.exit,0);stage='activation away and back';const raceCommit=call(a,'sessions:restore',{planId:racePlan.result.planId});await until(()=>win.contentView.children.some(v=>v.webContents!==win.webContents&&!raceContents.includes(v.webContents)&&v.webContents.isLoading()));await until(async()=>((await raceCounts())['/delayed']||0)===(raceBefore['/delayed']||0)+1);const raceState=await ui('state'),newId=raceState.tabs.find(t=>!raceOriginals.some(r=>r.id===t.id)).id;await ui('activateTab',baseIds[0]);await ui('activateTab',newId);assert.equal((await raceCommit).exit,1);await until(async()=> (await inventory()).length===raceOriginals.length);const raceAfter=await inventory();for(const old of raceOriginals)assert.ok(raceAfter.some(r=>r.id===old.id));const raceAfterCounts=await raceCounts();assert.equal(raceAfterCounts['/delayed'],(raceBefore['/delayed']||0)+1);assert.equal(raceAfterCounts['/second-race']||0,raceBefore['/second-race']||0);
const change=async modify=>{const p=(await overview()).policy;modify(p);p.version++;const proposed=await fetch(base+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)});assert.equal(proposed.status,202);const review=await proposed.json();const approval=await fetch(base+`/api/admin/policy-reviews/${review.reviewId}/approve`,{method:'POST',headers:{cookie:`sn_session=${process.env.SAFETYNET_SMOKE_REVIEWER}`,origin:base,'content-type':'application/json'},body:JSON.stringify({payloadHash:review.payloadHash,expectedVersion:review.expectedVersion,approve:true})});assert.equal(approval.status,200);await until(async()=>{const s=await ui('state');return s.ready&&s.policyVersion===p.version;});};
stage='central disable';plan=await call(a,'sessions:restore',{id});const count=async()=> (await page.executeJavaScript("fetch('/count').then(r=>r.json())")).requested;let before;await change(p=>{p.agents.scopes=p.agents.scopes.filter(s=>s!=='sessions:restore');});await wait(500);before=await count();assert.equal((await call(a,'sessions:restore',{planId:plan.result.planId})).exit,1);assert.equal(await count(),before);await change(p=>{p.agents.scopes.push('sessions:restore');});
stage='host restriction';plan=await call(a,'sessions:restore',{id});await change(p=>{p.agents.grants.find(g=>g.id==='session-a').allowedHosts=['127.0.0.2'];});await wait(500);before=await count();assert.equal((await call(a,'sessions:restore',{planId:plan.result.planId})).exit,1);assert.equal(await count(),before);await change(p=>{p.agents.grants.find(g=>g.id==='session-a').allowedHosts=['127.0.0.1'];});
stage='target revoke';plan=await call(a,'sessions:restore',{id});await change(p=>{p.agents.grants.find(g=>g.id==='session-a').enabled=false;});await wait(500);before=await count();assert.equal((await call(a,'sessions:restore',{planId:plan.result.planId})).exit,1);assert.equal(await count(),before);
stage='audit trace';const events=await until(async()=>{const e=(await overview()).events;return e.some(e=>e.metadata.agent?.action==='sessions:restore'&&e.metadata.agent.outcome==='completed')&&e;});for(const e of events)for(const text of ['PRIVATE_FORM_SENTINEL','PRIVATE_PASSWORD_SENTINEL','Research collection'])assert.ok(!JSON.stringify(e).includes(text));await wait(1500);const proofs=[];for(const action of ['sessions:save','sessions:list','sessions:restore']){let proof;for(const event of events.filter(e=>e.metadata.agent?.action===action&&e.metadata.agent.outcome==='completed'&&e.metadata.traceId)){const trace=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.metadata.traceId)).json(),spans=trace.spans||[];if(action==='sessions:restore'&&!spans.some(s=>s.operation==='sessions.native'))continue;const request=spans.find(s=>s.operation==='agent.request'),dispatch=spans.find(s=>s.operation==='agent.dispatch'&&s.parentSpanId===request?.spanId),agent=spans.find(s=>s.name==='browser.agent'&&s.operation===action&&s.parentSpanId===dispatch?.spanId);assert.ok(agent);for(const op of ['audit.serialize','audit.encrypt','audit.write','audit.fsync','audit.commit'])assert.ok(spans.some(s=>s.operation===op&&spans.some(p=>p.operation==='agent.audit'&&p.spanId===s.parentSpanId)),op);for(const op of action==='sessions:save'?['sessions.inspect','sessions.save']:action==='sessions:list'?['sessions.list','sessions.inspect']:['sessions.load','sessions.inspect','sessions.restore','sessions.preflight','sessions.native'])assert.ok(spans.some(s=>s.name==='browser.session'&&s.operation===op&&spans.some(p=>p.spanId===s.parentSpanId)),op);proof={action,traceId:event.metadata.traceId,stages:spans.map(({name,operation,parentSpanId,spanId,durationMs})=>({name,operation,parentSpanId,spanId,durationMs}))};break;}assert.ok(proof,action);proofs.push(proof);}let raceProof;for(const event of events.filter(e=>e.metadata.agent?.action==='sessions:restore'&&e.metadata.agent.outcome!=='completed'&&e.metadata.traceId)){const t=await(await fetch('http://127.0.0.1:4318/api/trace/'+event.metadata.traceId)).json();if(t.spans?.some(s=>s.operation==='sessions.rollback')){raceProof={traceId:event.metadata.traceId,stages:t.spans.map(({name,operation,parentSpanId,spanId,durationMs})=>({name,operation,parentSpanId,spanId,durationMs}))};break;}}assert.ok(raceProof,'Actual race rollback trace required');const health=await(await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'session-agent-controls-native-proof.json'),JSON.stringify({actualElectron:true,encryptedRestart:true,sameDevicePrincipal:true,benignQueryHashPreserved:true,groupsPreserved:true,existingTabsPreserved:true,noPageStateOrSecretsSaved:true,twoStage:true,crossActorDenied:true,replayDenied:true,staleRevisionDenied:true,activationAwayAndBackDenied:true,raceOwnedTabsCleaned:true,raceExistingTabsPreserved:true,raceSecondDestinationNotReleased:true,raceProof,centralDisableBeforeRelease:true,hostRestrictionBeforeRelease:true,targetedRevocationBeforeRelease:true,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites},proofs},null,2));console.log('Session agent controls native proof passed');for(const p of win.contentView.children.filter(v=>v.webContents!==win.webContents))require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p.webContents);app.exit(0);
}catch(error){fs.writeFileSync(path.resolve('artifacts/session-agent-controls-native-error.json'),JSON.stringify({stage,name:error.name,message:error.message},null,2));console.error(`Session fixture failed at ${stage}: ${error.name}`);for(const p of pages)if(!p.isDestroyed())require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(p);app.exit(1);}});
+22
View File
@@ -0,0 +1,22 @@
import {createServer} from 'node:https';
import {createRequire} from 'node:module';
import {mkdtempSync,mkdirSync,rmSync,readFileSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join,resolve,sep} from 'node:path';
import {spawn} from 'node:child_process';
import {randomBytes} from 'node:crypto';
import {createApp} from '../apps/server/app.mjs';
import {openStore,digest} from '../apps/server/store.mjs';
import {startTelemetry} from '../apps/server/telemetry.mjs';
const collector='http://127.0.0.1:4318';if(!(await fetch(collector+'/health')).ok)throw Error('Protected collector unavailable');
process.env.OTEL_EXPORTER_OTLP_ENDPOINT=collector;const telemetry=startTelemetry();
const temporary=mkdtempSync(join(tmpdir(),'safetynet-session-agent-'));mkdirSync('artifacts',{recursive:true});
if(!resolve(temporary).startsWith(resolve(tmpdir())+sep)||!temporary.split(/[\\/]/).at(-1).startsWith('safetynet-session-agent-'))throw Error('Unsafe temporary fixture path');
let requested=0;const paths={};const sink=createServer({cert:readFileSync('tests/fixtures/icap/cert.pem'),key:readFileSync('tests/fixtures/icap/key.pem')},(req,res)=>{if(req.url==='/count'){res.setHeader('content-type','application/json');res.end(JSON.stringify({requested,paths}));return;}requested++;const pathname=new URL(req.url,'https://fixture').pathname;paths[pathname]=(paths[pathname]||0)+1;res.setHeader('content-type','text/html');const respond=()=>res.end('<!doctype html><title>Public research fixture</title><input name="private" value="PRIVATE_FORM_SENTINEL"><input type="password" value="PRIVATE_PASSWORD_SENTINEL"><p>Public research</p>');if(pathname==='/delayed')setTimeout(respond,800);else respond();}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));
const origin=`https://127.0.0.1:${sink.address().port}`,store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');
const p=store.get('policy');p.browser.homeUrl=origin+'/research?q=public#section';p.agents={...p.agents,enabled:true,identityMode:'individual',scopes:['sessions','sessions:save','sessions:list','sessions:restore'],allowedHosts:['127.0.0.1'],grants:['session-a','session-b'].map(id=>({id,name:id,enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['sessions','sessions:save','sessions:list','sessions:restore'],allowedHosts:['127.0.0.1'],generation:1}))};
p.diagnostics={...p.diagnostics,enabled:true,mode:'debug',endpoint:collector,sampleRate:1,debugSampleRate:1,flushIntervalMs:250};store.put('policy',p);store.db.prepare('INSERT INTO enrollments VALUES(?,?,?)').run(digest(code),Date.now()+600000,'session-fixture');
const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement:true,requireIndependentPolicyApproval:true};const management=createApp(store,config).listen(0,'127.0.0.1');await new Promise(r=>management.once('listening',r));config.publicUrl=`http://127.0.0.1:${management.address().port}`;
const reviewer=randomBytes(32).toString('base64url');store.db.prepare('INSERT INTO sessions VALUES(?,?,?)').run(digest(reviewer),Date.now()+600000,JSON.stringify({sub:'independent-session-reviewer',role:'admin'}));
let exit=0;for(const phase of ['save','restore']){const child=spawn(createRequire(import.meta.url)('electron'),['scripts/session-agent-controls-smoke.cjs'],{windowsHide:true,stdio:'inherit',env:{...process.env,SAFETYNET_SMOKE_PHASE:phase,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_REVIEWER:reviewer,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_ORIGIN:origin}});const deadline=setTimeout(()=>child.kill(),180000);exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(deadline);if(exit!==0)break;}
for(const server of [management,sink])server.closeAllConnections();await Promise.all([new Promise(r=>management.close(r)),new Promise(r=>sink.close(r))]);store.db.close();await telemetry?.shutdown();rmSync(temporary,{recursive:true,force:true});process.exit(exit);
+1 -1
View File
@@ -14,7 +14,7 @@ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize
for(const file of ['apps/browser/upload-lease.cjs','apps/browser/file-upload-gate.cjs','apps/browser/request-source.cjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged upload binding source is stale: ${file}`);
assert.deepEqual(await fs.readFile(path.join(root,'resources/inspector/upload_lease.py')),await fs.readFile('apps/inspector/upload_lease.py'),'Packaged Windows upload lease helper is stale');
assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize('apps/browser/header-inspection.cjs')),await fs.readFile('apps/browser/header-inspection.cjs'),'Packaged outgoing header DLP source is stale');
for(const file of ['apps/browser/vault-agent-controls.cjs','apps/browser/vault.cjs','apps/browser/window-controls.cjs','apps/browser/history-snapshot.cjs','apps/browser/human-interaction.cjs','packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`);
for(const file of ['apps/browser/session-snapshot.cjs','apps/browser/agent-session-adapters.cjs','apps/browser/session-restore.cjs','apps/browser/vault-agent-controls.cjs','apps/browser/vault.cjs','apps/browser/window-controls.cjs','apps/browser/history-snapshot.cjs','apps/browser/human-interaction.cjs','packages/core/siem.mjs','packages/core/evidence.mjs','packages/core/evidence-payload.cjs','packages/core/interaction-facts.cjs','apps/browser/evidence-capture.cjs','apps/browser/evidence-delivery.cjs','packages/core/request-transfer-facts.cjs','packages/core/transfer-facts.cjs','packages/core/inspection-stages.cjs','apps/browser/history-navigation.cjs','apps/browser/agent-transport-client.cjs','apps/browser/agent-client.cjs','apps/browser/library-organization.cjs','packages/core/library.mjs','packages/core/branding.mjs','packages/core/file-transfer.cjs','packages/core/file-transfer.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged new security/branding source is stale: ${file}`);
for(const file of ['apps/browser/agent-grants.cjs','apps/browser/policy-assignment.cjs','packages/core/policy-groups.mjs','apps/browser/tab-registry.cjs','apps/browser/agent-adapters.cjs','apps/browser/agent-control.cjs','apps/browser/password-fill.cjs','apps/browser/diagnostics.cjs','packages/core/diagnostics.mjs','packages/core/security.mjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged device/session policy source is stale: ${file}`);
for(const file of ['apps/browser/main.cjs','apps/browser/preload.cjs','apps/browser/site-preload.cjs','apps/browser/clipboard-deny.cjs','apps/browser/clipboard-guard.cjs','apps/browser/clipboard-dom.cjs','apps/browser/page-clipboard-guard.cjs','apps/browser/startup-security.cjs','apps/browser/visual-capture.cjs','apps/browser/renderer.js','apps/browser/index.html','apps/browser/style.css','packages/core/clipboard.mjs']){
assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged source is stale: ${file}`);