Add policy-bound encrypted agent session restoration and audit traces
This commit is contained in:
1 parent
e8a654fb38
commit
b991ee9da6
27 files changed
+405
-24
No files matched your search
@@ -33,6 +33,9 @@ Use `agent-transport-client.cjs` or the trusted Electron helper. The generated n
|
||||
| Action | Arguments | Policy scope | Internal scope | Result |
|
||||
|---|---|---|---|---|
|
||||
| sessions | none | sessions | sessions:list | `[{id,title,foreground}]` |
|
||||
| sessions:save | name, sessionIds, optional id and expectedRevision | sessions:save | sessions:save | owned collection ID, revision, tabCount |
|
||||
| sessions:list | none | sessions:list | sessions:list | revision and DLP-inspected owned collections |
|
||||
| sessions:restore | id OR planId | sessions:restore | sessions:restore | one-use plan, then restored native tab IDs |
|
||||
| navigate | sessionId, url | navigate | browser:navigate | `{completed}` |
|
||||
| back | sessionId | back | browser:navigate | `{completed}` |
|
||||
| forward | sessionId | forward | browser:navigate | `{completed}` |
|
||||
|
||||
@@ -38,7 +38,7 @@ S Browser uses translucent materials, rounded controls, light/dark themes, and a
|
||||
| Observability | OpenTelemetry traces, metrics and sanitized logs; durable security events; ECS/CEF exports and HTTPS/mTLS SIEM delivery. Governed protected download metadata, completed clipboard/agent-click metadata and opt-in observed foreground clicks/form gestures. Full replay and content recording remain incomplete. |
|
||||
| Additional controls | Local encrypted password vault with explicit autofill and separately approved agent status/masked inventory/fill after human unlock; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. |
|
||||
|
||||
Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md).
|
||||
Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md). Separately approved [saved sessions](docs/agent-session-controls.md) retain locally encrypted URL/title/group collections; restoration rechecks current policy and creates managed tabs without saving form or authentication state.
|
||||
|
||||
Permissions are enforced by the backend and privileged browser code, not merely by hiding controls. Matching restrictive policies accumulate. Domain categories are curated company lists, not a subscription categorization feed.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ const http = require('node:http');
|
||||
const crypto = require('node:crypto');
|
||||
const channel=require('../../packages/core/policy-channel.cjs');
|
||||
|
||||
const APP_ACTIONS=Object.freeze(['vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']);
|
||||
const APP_ACTIONS=Object.freeze(['sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']);
|
||||
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
|
||||
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
const bounded = (value, max, name) => {
|
||||
@@ -16,9 +16,20 @@ function validate(body) {
|
||||
if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action');
|
||||
const args = body.args ?? {};
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments');
|
||||
const keys = { 'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
|
||||
const keys = { 'sessions:save':['name','sessionIds','id','expectedRevision'],'sessions:list':[],'sessions:restore':['id','planId'],'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
|
||||
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
|
||||
const clean = {};
|
||||
if(action==='sessions:save'){
|
||||
clean.name=bounded(args.name,80,'session name');
|
||||
if(!Array.isArray(args.sessionIds)||args.sessionIds.length<1||args.sessionIds.length>32||new Set(args.sessionIds).size!==args.sessionIds.length)throw fail(400,'Invalid session selection');
|
||||
clean.sessionIds=args.sessionIds.map(id=>bounded(id,128,'session id'));
|
||||
for(const key of ['id','expectedRevision'])if(args[key]!==undefined)clean[key]=bounded(args[key],36,key);
|
||||
}
|
||||
if(action==='sessions:restore'){
|
||||
if((args.id!==undefined)===(args.planId!==undefined))throw fail(400,'Invalid session restore');
|
||||
const key=args.planId!==undefined?'planId':'id';clean[key]=bounded(args[key],36,key);
|
||||
}
|
||||
if(action.startsWith('sessions:'))for(const key of ['id','expectedRevision','planId'])if(clean[key]!==undefined&&!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean[key]))throw fail(400,'Invalid session handle');
|
||||
if(action.startsWith('vault:')){
|
||||
if(args.sessionId!==undefined||action==='vault:fill')clean.sessionId=bounded(args.sessionId,128,'sessionId');
|
||||
if(action==='vault:fill'){clean.credentialRef=bounded(args.credentialRef,36,'credentialRef');if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean.credentialRef))throw fail(400,'Invalid credential reference');}
|
||||
@@ -59,6 +70,17 @@ function validate(body) {
|
||||
}
|
||||
// Never return adapter objects wholesale: session handles and sanitized text only.
|
||||
function project(action, result) {
|
||||
if(action.startsWith('sessions:')){
|
||||
const handle=value=>{if(typeof value!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value))throw fail(502,'Invalid session handle');return value;};
|
||||
if(action==='sessions:save')return {id:handle(result?.id),revision:handle(result?.revision),tabCount:sessionCount(result?.tabCount)};
|
||||
if(action==='sessions:restore'){
|
||||
if(result?.planId)return {planId:handle(result.planId),id:handle(result.id),tabCount:sessionCount(result.tabCount)};
|
||||
if(result?.completed!==true||!Array.isArray(result.sessionIds)||result.sessionIds.length<1||result.sessionIds.length>32||new Set(result.sessionIds).size!==result.sessionIds.length||!result.sessionIds.includes(result.activeSessionId))throw fail(502,'Invalid restored sessions');
|
||||
return {completed:true,sessionIds:result.sessionIds.map(handle),activeSessionId:handle(result.activeSessionId)};
|
||||
}
|
||||
if(!Array.isArray(result?.collections)||result.collections.length>16)throw fail(502,'Invalid session collections');
|
||||
return {revision:handle(result.revision),collections:result.collections.map(row=>{if(!Array.isArray(row.tabs)||row.tabs.length<1||row.tabs.length>32||!Number.isInteger(row.activeIndex)||row.activeIndex<0||row.activeIndex>=row.tabs.length)throw fail(502,'Invalid session collection');return {id:handle(row.id),name:bounded(row.name,80,'session name'),activeIndex:row.activeIndex,tabs:row.tabs.map(require('./agent-session-adapters.cjs').safeSessionTab)};})};
|
||||
}
|
||||
if(action==='vault:status'){
|
||||
if(!result||!['exists','locked','enabled','allowAutofill'].every(key=>typeof result[key]==='boolean'))throw fail(502,'Invalid vault response');
|
||||
return Object.fromEntries(['exists','locked','enabled','allowAutofill'].map(key=>[key,result[key]]));
|
||||
@@ -100,6 +122,7 @@ function project(action, result) {
|
||||
}
|
||||
return { completed: result?.completed !== false };
|
||||
}
|
||||
function sessionCount(value){if(!Number.isSafeInteger(value)||value<1||value>32)throw fail(502,'Invalid session count');return value;}
|
||||
|
||||
async function startAgentControl({ authorize, actions, audit, credentialFile, persistCredential, onError, traceOperation = async (_name,fn)=>fn(), timeoutMs = 10000, maxRequestsPerMinute = 60, principalMode = 'legacy', principals = [] } = {}) {
|
||||
if(credentialFile!==undefined)throw new TypeError('Plaintext pairing files are prohibited; use protected persistCredential');
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
const credential=/password|passwd|api.?key|access.?token|refresh.?token|client.?secret|authorization|credential|session.?id|-----BEGIN|\b(?:sk-|gh[pousr]_)[A-Za-z0-9_-]{16,}/i;
|
||||
function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f]/.test(value))throw Error('Invalid session metadata');let decoded=value;for(let i=0;i<4;i++){if(credential.test(decoded))throw Error('Sensitive session metadata');try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}return value;}
|
||||
function sensitiveURL(value){for(let i=0;i<5;i++){if(/[?&#](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|credential|session|sid|auth|samlresponse|samlrequest|assertion|ticket|id_token)=/i.test(value)||/\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|[?#]|$)/i.test(value))return true;try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{return true;}}return false;}
|
||||
function safeTab(row){if(!row||Object.keys(row).some(k=>!['url','title','group'].includes(k)))throw Error('Invalid session metadata');const title=safeText(row.title,200),raw=safeText(row.url,4096),url=new URL(raw);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||sensitiveURL(raw)||/\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|$)/i.test(decodeURIComponent(url.pathname)))throw Error('Unsafe session destination');return {url:url.href,title,...(row.group===undefined?{}:{group:safeText(row.group,80)})};}
|
||||
function createAgentSessionAdapters({getContext,getSessions,contextCurrent,getOwner,store,inspect,restore,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}){
|
||||
if([getContext,getSessions,contextCurrent,getOwner,inspect,restore].some(fn=>typeof fn!=='function')||!store)throw TypeError('Session callbacks required');
|
||||
const plans=new Map();
|
||||
const snapshot=(action,signal)=>{const c=getContext();if(signal?.aborted||!c?.ready||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||(c.policy.expiresAt&&Date.parse(c.policy.expiresAt)<=now()))throw Error('Agent session scope denied');const owner=getOwner(c);if(!owner||typeof owner.deviceId!=='string'||typeof owner.ownerId!=='string')throw Error('Session owner unavailable');return {...c,action,owner,actorBinding:JSON.stringify(c.actor??null),version:c.policyVersion??c.policy.version};};
|
||||
const current=(c,signal,allowActivation=false)=>{const n=snapshot(c.action,signal);if(n.policy!==c.policy||n.version!==c.version||n.actorBinding!==c.actorBinding||JSON.stringify(n.owner)!==JSON.stringify(c.owner)||(c.token&&!contextCurrent(allowActivation&&typeof c.token==='object'?{...c.token,foreground:false,activation:null}:c.token)))throw Error('Session authority changed');};
|
||||
const destination=(tab,c)=>{const url=new URL(tab.url),hosts=c.policy.agents.allowedHosts;if(typeof c.destinationAllowed!=='function'||c.destinationAllowed(tab.url,c.policy)!==true||!Array.isArray(hosts)||(hosts.length&&!hosts.some(h=>url.hostname===h||(h.startsWith('*.')&&url.hostname.endsWith(h.slice(1))))))throw Error('Session destination denied');};
|
||||
const classify=async(value,c,signal)=>{const text=JSON.stringify(value);if(text.length>200000)throw Error('Session metadata too large');const v=await traceOperation('inspect',()=>inspect(text,c));current(c,signal);if(v?.decision!=='allow'||v.coverage?.complete===false||/uninspectable|size-limit|inspection-incomplete|unavailable|failure|failed|timeout|unsupported|encrypted|parser/i.test(v.ruleId||''))throw Error('Session metadata inspection denied');};
|
||||
const load=async(c,id)=>{const result=await traceOperation('load',()=>store.load({...c.owner,id}));if(!result||typeof result.revision!=='string')throw Error('Session collection unavailable');const {revision,...collection}=result;return {revision,collection};};
|
||||
return {
|
||||
'sessions:save':async(args={}, {signal}={})=>{const c=snapshot('sessions:save',signal),name=safeText(args.name,80);if(!name.trim()||!Array.isArray(args.sessionIds)||args.sessionIds.length<1||args.sessionIds.length>32||new Set(args.sessionIds).size!==args.sessionIds.length)throw Error('Invalid session selection');const inventory=getSessions(),bindings=[];const tabs=args.sessionIds.map(id=>{const row=inventory.find(r=>r.id===id),tab=getContext(id);if(!row||!tab?.ready||!tab.token||!contextCurrent(tab.token))throw Error('Session selection changed');const value=safeTab({title:row.title,url:tab.webContents.getURL(),...(row.group===undefined?{}:{group:row.group})});destination(value,c);bindings.push({id,token:tab.token,url:value.url,title:row.title,group:row.group});return value;});const assertCurrent=()=>{current(c,signal);const rows=getSessions();for(const b of bindings){const tab=getContext(b.id);if(!contextCurrent(b.token)||tab?.webContents?.getURL()!==b.url||rows.find(r=>r.id===b.id)?.title!==b.title||rows.find(r=>r.id===b.id)?.group!==b.group)throw Error('Session selection changed');}};await classify({name,tabs},c,signal);assertCurrent();const existing=await store.list(c.owner);assertCurrent();if(args.expectedRevision!==undefined&&args.expectedRevision!==existing.revision)throw Error('Session collection changed');const activeIndex=Math.max(0,args.sessionIds.indexOf(c.sessionId));const result=await traceOperation('save',()=>store.save({...(args.id?{id:args.id}:{}),name,tabs,activeIndex},{...c.owner,expectedRevision:existing.revision,assertCurrent}));assertCurrent();return {id:result.id,revision:result.revision,tabCount:tabs.length};},
|
||||
'sessions:list':async(_args={}, {signal}={})=>{const c=snapshot('sessions:list',signal),result=await traceOperation('list',()=>store.list(c.owner));current(c,signal);if(!Array.isArray(result.collections)||result.collections.length>16)throw Error('Invalid session collections');const collections=result.collections.map(row=>({id:row.id,name:safeText(row.name,80),tabs:row.tabs.map(safeTab),activeIndex:row.activeIndex}));for(const row of collections)for(const tab of row.tabs)destination(tab,c);await classify(collections,c,signal);current(c,signal);if((await store.list(c.owner)).revision!==result.revision)throw Error('Session collection changed');current(c,signal);return {revision:result.revision,collections};},
|
||||
'sessions:restore':async(args={}, {signal}={})=>{const c=snapshot('sessions:restore',signal);if(!args.planId){const result=await load(c,args.id);current(c,signal);const collection={id:result.collection.id,name:safeText(result.collection.name,80),tabs:result.collection.tabs.map(safeTab),activeIndex:result.collection.activeIndex};if(!collection.tabs.length||collection.tabs.length>32)throw Error('Invalid session collection');for(const tab of collection.tabs)destination(tab,c);await classify(collection,c,signal);if((await store.list(c.owner)).revision!==result.revision)throw Error('Session collection changed');current(c,signal);for(const [id,p] of plans)if(p.expires<=now())plans.delete(id);while(plans.size>=32)plans.delete(plans.keys().next().value);const planId=randomUUID();plans.set(planId,{c,collection,revision:result.revision,expires:now()+60000});return {planId,id:collection.id,tabCount:collection.tabs.length};}const plan=plans.get(args.planId);plans.delete(args.planId);if(!plan||plan.expires<=now()||plan.c.policy!==c.policy||plan.c.actorBinding!==c.actorBinding||JSON.stringify(plan.c.owner)!==JSON.stringify(c.owner))throw Error('Session restore plan unavailable');current(plan.c,signal);const result=await load(c,plan.collection.id);if(result.revision!==plan.revision||JSON.stringify(result.collection)!==JSON.stringify(plan.collection))throw Error('Session collection changed');for(const tab of plan.collection.tabs)destination(tab,c);await classify(plan.collection,c,signal);if((await store.list(c.owner)).revision!==plan.revision)throw Error('Session collection changed');current(c,signal);return traceOperation('restore',()=>restore(plan.collection.tabs,{activeIndex:plan.collection.activeIndex,signal,assertCurrent:()=>{current(c,signal,true);for(const tab of plan.collection.tabs)destination(tab,c);},revision:plan.revision,owner:c.owner}));}
|
||||
};
|
||||
}
|
||||
module.exports={createAgentSessionAdapters,safeSessionTab:safeTab};
|
||||
@@ -16,6 +16,11 @@ const components=new Set(['browser','ipc','agent','management','navigation','net
|
||||
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
|
||||
names.add('browser.vault');
|
||||
for(const operation of ['vault:status','vault:list','vault:fill','vault.inventory.inspect','vault.fill.visual','vault.fill.native'])operations.add(operation);
|
||||
// Session collection stages use fixed identifiers; names and URLs are never attributes.
|
||||
names.add('browser.session');
|
||||
components.add('sessions');
|
||||
for(const operation of ['sessions:save','sessions:list','sessions:restore','sessions.inspect','sessions.load','sessions.list','sessions.save','sessions.restore','sessions.authorize','sessions.preflight','sessions.native','sessions.rollback'])operations.add(operation);
|
||||
for(const operation of ['audit.serialize','audit.encrypt','audit.write','audit.fsync','audit.commit'])operations.add(operation);
|
||||
const decisions=new Set(['allow','block','deny','unknown']),statuses=new Set(['ok','error','cancelled','timeout']);
|
||||
const requestTypes=new Set(['mainFrame','subFrame','stylesheet','script','image','font','object','xhr','ping','cspReport','media','webSocket','other']);
|
||||
for(const name of ['browser.upload.lease','browser.upload.part','browser.upload.release','browser.upload.fault'])names.add(name);
|
||||
@@ -64,6 +69,11 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
|
||||
async function span(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
|
||||
activeSpans++;const started=performance.now();let status='ok';try{const result=await storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
|
||||
}
|
||||
// Synchronous persistence must retain its native return/throw contract. Never
|
||||
// wrap fsync or DPAPI in a promise merely to add diagnostic timing.
|
||||
function spanSync(name,input,fn){if(typeof fn!=='function')throw TypeError('Diagnostics operation requires a function');const current=generation;if(!current?.active||!names.has(name)||activeSpans>=128)return fn();let nativeSpan;try{const parent=storage.getStore(),context=parent?.generation===current?trace.setSpan(ROOT_CONTEXT,parent.span):ROOT_CONTEXT;nativeSpan=current.tracer.startSpan(name,{attributes:{...current.context,...attributes(input)}},context);}catch{return fn();}
|
||||
activeSpans++;const started=performance.now();let status='ok';try{const result=storage.run({generation:current,span:nativeSpan},fn);try{nativeSpan.setStatus({code:SpanStatusCode.OK});}catch{}return result;}catch(error){status='error';try{nativeSpan.setStatus({code:SpanStatusCode.ERROR});nativeSpan.setAttribute('error.type',error instanceof TypeError?'TypeError':error instanceof RangeError?'RangeError':'Error');}catch{}throw error;}finally{activeSpans--;try{const sampled=nativeSpan.isRecording();nativeSpan.setAttribute('safetynet.status',status);nativeSpan.setAttribute('safetynet.duration.ms',Math.max(0,performance.now()-started));nativeSpan.end();if(current.active&&sampled)current.logger.emit({severityNumber:9,severityText:'INFO',body:name,attributes:{...current.context,...attributes(input),'safetynet.status':status,'safetynet.duration.ms':Math.max(0,performance.now()-started)},context:trace.setSpan(ROOT_CONTEXT,nativeSpan)});}catch{}}
|
||||
}
|
||||
// Durable events may be delivered by a flusher started under another event's
|
||||
// async context. Adopt only data-property IDs; never inherit that unrelated
|
||||
// parent, invoke caller getters, or allow diagnostic failure to repeat work.
|
||||
@@ -84,6 +94,6 @@ function createDiagnostics(){const storage=new AsyncLocalStorage();let generatio
|
||||
}
|
||||
async function flush(){const current=generation;if(!current?.active)return;await Promise.allSettled([current.tracerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs}),current.loggerProvider.forceFlush({timeoutMillis:current.config.exportTimeoutMs})]);}
|
||||
async function shutdown(){if(closed)return;await flush();closed=true;const current=generation;generation=null;retire(current);await Promise.allSettled([...retiring]);}
|
||||
return {configure,span,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
|
||||
return {configure,span,spanSync,event,importStages,withCorrelation,flush,shutdown,correlation:()=>{const parent=storage.getStore();if(!parent||parent.generation!==generation||!generation?.active||!parent.span.isRecording())return {};const ids=parent.span.spanContext();return {traceId:ids.traceId,spanId:ids.spanId};},status:()=>({enabled:!!generation?.active,mode:generation?.config.mode||'operational',failedExports:generation?.failedExports||0,lifetimeFailedExports,activeSpans})};
|
||||
}
|
||||
module.exports={createDiagnostics,attributes,endpointAllowed};
|
||||
+21
-6
@@ -192,7 +192,8 @@ function requireVault(){
|
||||
}
|
||||
function persistAudit(){
|
||||
requireEncryption();const target=path.join(app.getPath('userData'),'audit-queue.bin'),temporary=target+'.'+require('node:crypto').randomUUID()+'.tmp';let fd;
|
||||
try{fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,safeStorage.encryptString(JSON.stringify(auditQueue)));fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;fs.renameSync(temporary,target);}finally{if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch{}}
|
||||
const stage=(operation,fn)=>diagnostics.spanSync('browser.security',diagnosticContext({operation}),fn);
|
||||
try{const serialized=stage('audit.serialize',()=>JSON.stringify(auditQueue));const encrypted=stage('audit.encrypt',()=>safeStorage.encryptString(serialized));stage('audit.write',()=>{fd=fs.openSync(temporary,'wx',0o600);fs.writeFileSync(fd,encrypted);});stage('audit.fsync',()=>fs.fsyncSync(fd));stage('audit.commit',()=>{fs.closeSync(fd);fd=undefined;fs.renameSync(temporary,target);});}finally{if(fd!==undefined)fs.closeSync(fd);try{fs.unlinkSync(temporary);}catch{}}
|
||||
}
|
||||
function enforceEvidencePolicy(){
|
||||
if(!policy)return;const delivery=require('./evidence-delivery.cjs');
|
||||
@@ -415,6 +416,7 @@ async function ensureAgentAPI(){
|
||||
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'agent-adapter'},policy));if(!payload)return;
|
||||
const actor=agentActorStorage.getStore();await auditAgent({action:'click',outcome:'completed',sessionId:metadata.sessionId,...(actor?.principalId?{principalId:actor.principalId,generation:actor.generation}:{}),protectedInteraction:payload});
|
||||
}});
|
||||
Object.assign(actions,agentSessionControls());
|
||||
for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};}
|
||||
for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{
|
||||
if(context.principalId&&['navigate','back','forward','reload','history:go','vault:fill','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
|
||||
@@ -427,7 +429,7 @@ async function ensureAgentAPI(){
|
||||
const actor={principalId,generation,action};
|
||||
if(!actorGrantAllowed(actor,action))return {allowed:false};
|
||||
const checkURL=url=>actorDestinationAllowed(url,actor);
|
||||
if(action==='sessions')return {allowed:true,role:'agent',scopes:[scope]};
|
||||
if(action==='sessions'||['sessions:save','sessions:list','sessions:restore'].includes(action))return {allowed:true,role:'agent',scopes:[scope]};
|
||||
if(action==='bookmarks:mutate'&&args.bookmark?.url&&!checkURL(args.bookmark.url))return {allowed:false};
|
||||
if(action==='bookmarks:mutate'&&args.action!=='add'){const existing=personalBookmarks.items.find(row=>row.id===(args.id||args.bookmark?.id));if(!existing||!checkURL(existing.url))return {allowed:false};}
|
||||
if(action==='tabs:create'&&args.url&&!checkURL(args.url))return {allowed:false};
|
||||
@@ -627,21 +629,34 @@ function installSiteHooks(tab){
|
||||
});
|
||||
}
|
||||
function createNativeTab(input={}){return diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input));}
|
||||
async function createNativeTabImpl(input={}){
|
||||
async function createNativeTabImpl(input={},controls={}){
|
||||
if(!input||typeof input!=='object'||Object.keys(input).some(key=>!['group','url'].includes(key)))throw Error('Invalid native tab request');
|
||||
if(input.group)requireManagedSection('library');
|
||||
const tab=tabs.create({group:input.group||''});tab.security.agentAuthority=input.agentAuthority||agentActorStorage.getStore()?.principalId&&{...agentActorStorage.getStore()};installSiteHooks(tab);
|
||||
controls.assertCurrent?.();const tab=tabs.create({group:input.group||''});
|
||||
try{
|
||||
controls.onCreated?.(tab);tab.security.agentAuthority=agentActorStorage.getStore()?.principalId&&{...agentActorStorage.getStore()};installSiteHooks(tab);
|
||||
await tab.view.webContents.loadURL('about:blank');
|
||||
controls.assertCurrent?.();
|
||||
await diagnosticSpan('browser.clipboard',{operation:'create',sessionId:tab.id},()=>attachPageClipboardGuard(tab.view.webContents,()=>{clipboardFatal=true;quarantine('Managed clipboard protection failed; restart the browser');audit('clipboard.block','clipboard-primitive-unavailable');app.quit();}));
|
||||
tab.guarded=true;selectTab(tab.id);
|
||||
if(input.url){if(tab.security.agentAuthority){await agentActorStorage.run(tab.security.agentAuthority,()=>navigate({url:input.url,tabId:tab.id}));}else await navigate({url:input.url,tabId:tab.id});}emit();return state();
|
||||
controls.assertCurrent?.();tab.guarded=true;selectTab(tab.id);controls.onActivated?.(tab);controls.assertCurrent?.();
|
||||
if(input.url){if(tab.security.agentAuthority){await agentActorStorage.run(tab.security.agentAuthority,()=>navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal}));}else await navigate({url:input.url,tabId:tab.id},undefined,{signal:controls.signal});}controls.assertCurrent?.();emit();return state();
|
||||
}catch(error){if(tabs.all().includes(tab)){tabs.remove(tab.id);if(tabs.active()?.guarded)selectTab(tabs.active().id);}throw error;}
|
||||
}
|
||||
|
||||
function updateManagedAppearance(input){requireManagedSection('appearance');appearancePreferences={...appearancePreferences,...validateAppearance(input)};persistAppearance();emit();return managedSettings();}
|
||||
async function closeNativeTab(id){tabs.remove(id);if(!tabs.all().length)await createNativeTab();else selectTab(tabs.active().id);emit();return state();}
|
||||
function groupNativeTab(input){requireManagedSection('library');if(libraryOrganizer?.isBusy())throw Error('Library organization is running');if(!input||typeof input.group!=='string'||input.group.length>40)throw Error('Invalid native tab group');tabs.get(input.id).group=input.group;emit();return state();}
|
||||
let sessionSnapshotStore;
|
||||
function agentSessionControls(){
|
||||
requireEncryption();sessionSnapshotStore??=require('./session-snapshot.cjs').createSessionSnapshotStore({filePath:path.join(app.getPath('userData'),'managed-sessions.bin'),protect:bytes=>safeStorage.encryptString(bytes.toString('base64')),unprotect:bytes=>Buffer.from(safeStorage.decryptString(bytes),'base64')});
|
||||
const sessionContext=id=>{const context=agentContext(id),actor=agentActorStorage.getStore();return {...context,ready:context.ready&&actorGrantAllowed(actor,actor?.action),actor:context.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};};
|
||||
return require('./agent-session-adapters.cjs').createAgentSessionAdapters({getContext:sessionContext,getSessions:()=>tabs.all().map(tab=>({id:tab.id,title:tab.title,group:tab.group})),contextCurrent:currentTabContext,
|
||||
getOwner:context=>({deviceId:device.id,ownerId:JSON.stringify([policy.agents.identityMode,context.actor.agentId,context.actor.generation])}),store:sessionSnapshotStore,inspect:inspectManagedAgentText,
|
||||
traceOperation:(operation,fn)=>diagnosticSpan('browser.session',{operation:'sessions.'+operation},fn),
|
||||
restore:(collection,options)=>require('./session-restore.cjs').restoreNativeSession(collection,options,{registry:tabs,store:sessionSnapshotStore,
|
||||
create:async(input,controls)=>{const result=await diagnosticSpan('browser.tab',{operation:'create'},()=>createNativeTabImpl(input,controls));return result.activeTabId;},activate:id=>{selectTab(id);emit();},traceOperation:(operation,fn)=>diagnosticSpan('browser.session',{operation:'sessions.'+operation},fn)})
|
||||
});
|
||||
}
|
||||
function agentAppControls(){
|
||||
const actorIdentity=()=>{const actor=agentActorStorage.getStore();return actor?.principalId?`${actor.principalId}:${actor.generation}`:'legacy';};
|
||||
const windowControls=require('./window-controls.cjs').createWindowControls({getWindow:()=>win,getContext:()=>({ready:ready&&featureAllowed(managedSettings(),'agents'),policy,policyVersion:policy?.version,deviceId:device?.id,actor:actorIdentity()}),isCurrent:context=>ready&&policy===context.policy&&device?.id===context.deviceId&&actorIdentity()===context.actor,authorize:action=>actorGrantAllowed(agentActorStorage.getStore(),action),traceOperation:(operation,fn)=>diagnosticSpan('browser.window',{operation},fn)});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
// Restoration creates managed native tabs; it never imports renderer pageState.
|
||||
async function restoreNativeSession(tabs,{activeIndex,signal,assertCurrent,revision,owner},{registry,store,create,activate,traceOperation=async(_name,fn)=>fn()}={}){
|
||||
if(!Array.isArray(tabs)||!tabs.length||tabs.length>32||!Number.isInteger(activeIndex)||activeIndex<0||activeIndex>=tabs.length||typeof assertCurrent!=='function')throw Error('Invalid managed session restore');
|
||||
const originals=registry.all().map(tab=>({id:tab.id,contents:tab.contents})),initialActive=registry.active()?.id,created=[];let expectedActive=initialActive;const epoch=()=>{const value=registry.context(registry.active()?.id,{foreground:true}).activation;if(!Number.isSafeInteger(value)||value<0)throw Error('Managed restore activation unavailable');return value;};let expectedEpoch=epoch();const ownActivation=id=>{const next=epoch(),required=expectedEpoch+(id===expectedActive?0:1);if(next!==required||registry.active()?.id!==id)throw Error('Managed restore activation changed');expectedActive=id;expectedEpoch=next;};
|
||||
const current=()=>{
|
||||
assertCurrent();if(signal?.aborted||store.list(owner).revision!==revision||registry.all().length!==originals.length+created.length||registry.active()?.id!==expectedActive||epoch()!==expectedEpoch)throw Error('Managed restore context changed');
|
||||
for(const row of [...originals,...created])if(registry.get(row.id).contents!==row.contents||row.contents.isDestroyed())throw Error('Managed restore tab changed');
|
||||
};
|
||||
await traceOperation('preflight',()=>{current();if(originals.length+tabs.length>32)throw Error('Native tab capacity reached');});
|
||||
try{
|
||||
for(const tab of tabs){current();const result=await traceOperation('native',()=>create({url:tab.url,...(tab.group?{group:tab.group}:{})},{signal,assertCurrent:current,onCreated:native=>{if(!native||originals.some(row=>row.id===native.id)||created.some(row=>row.id===native.id)||!registry.all().some(row=>row.id===native.id&&row.contents===native.contents))throw Error('Managed restore ownership mismatch');created.push({id:native.id,contents:native.contents});},onActivated:native=>ownActivation(native.id)}));current();if(result!==created.at(-1)?.id)throw Error('Managed restore tab mismatch');}
|
||||
current();activate(created[activeIndex].id);ownActivation(created[activeIndex].id);current();return {completed:true,sessionIds:created.map(row=>row.id),activeSessionId:expectedActive};
|
||||
}catch(error){
|
||||
// Closing owned tabs cannot undo network requests already sent before failure.
|
||||
let rollbackFailed=false;await traceOperation('rollback',()=>{for(const row of [...created].reverse()){try{if(registry.all().some(tab=>tab.id===row.id&&tab.contents===row.contents))registry.remove(row.id);}catch{rollbackFailed=true;}}try{if(initialActive&®istry.all().some(tab=>tab.id===initialActive&&!tab.contents.isDestroyed()))activate(initialActive);}catch{rollbackFailed=true;}});if(rollbackFailed)throw Error('Managed session rollback incomplete',{cause:error});throw error;
|
||||
}
|
||||
}
|
||||
module.exports={restoreNativeSession};
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
const fs=require('node:fs'),path=require('node:path'),{randomUUID}=require('node:crypto');
|
||||
const MAX_FILE=1048576, UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const fail=()=>{throw Error('Managed session collection unavailable');};
|
||||
const secret=s=>/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:AKIA|ASIA)[A-Z0-9]{16}\b|\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,})\b|\b(?:password|passwd|api[_ -]?key|access[_ -]?token|client[_ -]?secret)\s*[:=]\s*["']?[^\s"']{4,}/i.test(s);
|
||||
function safeText(value,max){if(typeof value!=='string'||value.length>max||/[\u0000-\u001f\u007f]/.test(value))fail();let decoded=value;for(let i=0;i<5;i++){if(secret(decoded))fail();try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}if(secret(decoded))fail();return value;}
|
||||
// Preserve benign query/fragment semantics; identifiable authentication material
|
||||
// and authentication routes are never reusable collection destinations.
|
||||
function unsafeURL(url){let decoded=url.href;for(let i=0;i<5;i++){if(/[?&#;](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|auth|credential|session|sid|samlresponse|samlrequest|assertion|ticket|id[_-]?token)=/i.test(decoded))return true;try{const next=decodeURIComponent(decoded);if(next===decoded)break;decoded=next;}catch{break;}}let pathname=url.pathname;for(let i=0;i<5;i++){try{const next=decodeURIComponent(pathname);if(next===pathname)break;pathname=next;}catch{break;}}return /\/(?:login|signin|sign-in|auth|oauth|sso|password|reset|callback)(?:\/|$)/i.test(pathname);}
|
||||
function sanitizeSessionCollection(input){
|
||||
if(!input||typeof input!=='object'||Array.isArray(input)||Object.keys(input).some(k=>!['id','name','tabs','activeIndex'].includes(k)))fail();
|
||||
if(input.id!==undefined&&!UUID.test(input.id))fail();
|
||||
const name=safeText(input.name,100);if(!name.trim()||!Array.isArray(input.tabs)||input.tabs.length<1||input.tabs.length>32||!Number.isInteger(input.activeIndex)||input.activeIndex<0||input.activeIndex>=input.tabs.length)fail();
|
||||
const tabs=input.tabs.map(row=>{if(!row||typeof row!=='object'||Array.isArray(row)||Object.keys(row).some(k=>!['url','title','group'].includes(k)))fail();const value=safeText(row.url,4096);let url;try{url=new URL(value);}catch{fail();}if(!['http:','https:'].includes(url.protocol)||url.username||url.password||unsafeURL(url))fail();return {url:url.href,title:safeText(row.title,200),...(row.group===undefined?{}:{group:safeText(row.group,100)})};});
|
||||
const result={...(input.id?{id:input.id}:{}),name,tabs,activeIndex:input.activeIndex};if(Buffer.byteLength(JSON.stringify(result))>196608)fail();return result;
|
||||
}
|
||||
function createSessionSnapshotStore({filePath,protect,unprotect}={}){
|
||||
if(typeof filePath!=='string'||!path.isAbsolute(filePath)||typeof protect!=='function'||typeof unprotect!=='function')fail();
|
||||
const binding=({deviceId,ownerId}={})=>{if(typeof deviceId!=='string'||!UUID.test(deviceId)||typeof ownerId!=='string'||ownerId.length<1||ownerId.length>512)fail();return {deviceId,ownerId};};
|
||||
function read(){if(!fs.existsSync(filePath))return {format:1,revision:randomUUID(),collections:[]};const stat=fs.lstatSync(filePath);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||stat.size<1||stat.size>MAX_FILE)fail();let encrypted,plain,fd;try{fd=fs.openSync(filePath,fs.constants.O_RDONLY|(fs.constants.O_NOFOLLOW||0));const current=fs.fstatSync(fd);if(current.ino!==stat.ino||current.dev!==stat.dev||current.nlink!==1||current.size>MAX_FILE)fail();encrypted=fs.readFileSync(fd);if(encrypted.length>MAX_FILE)fail();plain=unprotect(encrypted);if(!Buffer.isBuffer(plain)||plain.length>MAX_FILE)fail();const data=JSON.parse(plain.toString('utf8'));if(data.format!==1||!UUID.test(data.revision)||!Array.isArray(data.collections)||data.collections.length>16)fail();const ids=new Set();data.collections=data.collections.map(row=>{binding(row);const collection=sanitizeSessionCollection(row.collection);if(!collection.id||ids.has(collection.id))fail();ids.add(collection.id);return {deviceId:row.deviceId,ownerId:row.ownerId,collection};});return data;}catch{fail();}finally{if(fd!==undefined)fs.closeSync(fd);plain?.fill(0);encrypted?.fill(0);}}
|
||||
function write(data,options){let plain,encrypted,temp,fd;try{plain=Buffer.from(JSON.stringify(data));if(plain.length>MAX_FILE)fail();encrypted=protect(plain);if(!Buffer.isBuffer(encrypted)||encrypted.length<1||encrypted.length>MAX_FILE)fail();fs.mkdirSync(path.dirname(filePath),{recursive:true});if(fs.existsSync(filePath)){const stat=fs.lstatSync(filePath);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1)fail();}temp=filePath+'.'+randomUUID()+'.tmp';fd=fs.openSync(temp,'wx',0o600);fs.writeFileSync(fd,encrypted);fs.fsyncSync(fd);fs.closeSync(fd);fd=undefined;if(state().revision!==options.expectedRevision)fail();const checked=options.assertCurrent?.();if(checked&&typeof checked.then==='function')fail();if(state().revision!==options.expectedRevision)fail();fs.renameSync(temp,filePath);temp=null;}finally{if(fd!==undefined)fs.closeSync(fd);if(temp)try{fs.unlinkSync(temp);}catch{}plain?.fill(0);encrypted?.fill(0);}}
|
||||
let emptyRevision=randomUUID();
|
||||
const state=()=>fs.existsSync(filePath)?read():{format:1,revision:emptyRevision,collections:[]};
|
||||
const matches=(row,b)=>row.deviceId===b.deviceId&&row.ownerId===b.ownerId;
|
||||
return {
|
||||
list(options){const b=binding(options),data=state();return {revision:data.revision,collections:data.collections.filter(row=>matches(row,b)).map(row=>structuredClone(row.collection))};},
|
||||
load(options){const b=binding(options);if(!UUID.test(options.id))fail();const data=state(),row=data.collections.find(row=>matches(row,b)&&row.collection.id===options.id);if(!row)fail();return {revision:data.revision,...structuredClone(row.collection)};},
|
||||
save(input,options){const b=binding(options),data=state();if(options.expectedRevision!==data.revision)fail();const collection=sanitizeSessionCollection(input);const existing=collection.id&&data.collections.find(row=>row.collection.id===collection.id);if(collection.id&&(!existing||!matches(existing,b)))fail();if(!existing&&data.collections.length>=16)fail();collection.id??=randomUUID();if(existing)existing.collection=collection;else data.collections.push({...b,collection});data.revision=randomUUID();write(data,options);emptyRevision=data.revision;return {id:collection.id,revision:data.revision};},
|
||||
remove(options){const b=binding(options),data=state();if(options.expectedRevision!==data.revision||!UUID.test(options.id))fail();const index=data.collections.findIndex(row=>matches(row,b)&&row.collection.id===options.id);if(index<0)fail();data.collections.splice(index,1);data.revision=randomUUID();write(data,options);return {revision:data.revision};}
|
||||
};
|
||||
}
|
||||
module.exports={sanitizeSessionCollection,createSessionSnapshotStore};
|
||||
@@ -6,7 +6,7 @@ let credential='',policy,presetPreview,managedBookmarks=[],personalBookmarks=[],
|
||||
|
||||
const managedSections=['appearance','library','ai','passwords','documentScan','agents'];
|
||||
|
||||
const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill'];
|
||||
const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill','sessions:save','sessions:list','sessions:restore'];
|
||||
|
||||
function status(message,error=false){$('status').textContent=message;$('status').className=error?'error':'';}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ function renderAgentGrants(){
|
||||
const enabled=agentGrantField(grid,'Enabled');enabled.type='checkbox';enabled.checked=grant.enabled;enabled.disabled=!agentGrantsEditable;enabled.addEventListener('change',()=>{grant.enabled=enabled.checked;changedAgentGrants();});
|
||||
const expiry=agentGrantField(grid,'Expiry (UTC)');expiry.type='text';expiry.value=grant.expiresAt;expiry.placeholder='2026-12-31T23:59:59Z';expiry.disabled=!agentGrantsEditable;expiry.addEventListener('input',()=>{grant.expiresAt=expiry.value.trim();changedAgentGrants();});
|
||||
const hosts=agentGrantField(grid,'Additional destination allowlist','textarea');hosts.value=grant.allowedHosts.join('\n');hosts.placeholder='Empty adds no destination restriction';hosts.disabled=!agentGrantsEditable;hosts.addEventListener('input',()=>{grant.allowedHosts=hosts.value.split('\n').map(v=>v.trim()).filter(Boolean);changedAgentGrants();});
|
||||
const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. Vault status, masked listing and exact-origin autofill require separate capabilities and human unlock. Agents cannot unlock the vault or export passwords. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox);
|
||||
const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='Session saving, listing and restoration require separate capabilities. Saved URLs and tab group metadata never include passwords, form values or page state. Restoration rechecks current policy and does not restore revoked agent authority. History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. Vault status, masked listing and exact-origin autofill require separate capabilities and human unlock. Agents cannot unlock the vault or export passwords. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox);
|
||||
for(const scope of agentScopes){const label=document.createElement('label');const check=document.createElement('input');check.type='checkbox';check.checked=grant.scopes.includes(scope);check.disabled=!agentGrantsEditable;label.append(check,document.createTextNode(scope));scopes.append(label);check.addEventListener('change',()=>{grant.scopes=agentScopes.filter(s=>s===scope?check.checked:grant.scopes.includes(s));changedAgentGrants();});}
|
||||
const actions=document.createElement('div');actions.className='row';const generation=document.createElement('span');generation.textContent=`Credential generation: ${grant.generation}`;const rotate=document.createElement('button');rotate.type='button';rotate.className='secondary';rotate.textContent='Invalidate previous credentials';rotate.disabled=!agentGrantsEditable||grant.generation>=Number.MAX_SAFE_INTEGER;rotate.addEventListener('click',()=>{grant.generation+=1;renderAgentGrants();changedAgentGrants();agentGrantStatus('Credential generation changed in draft. Publish after review, then pair this identity again locally.');});const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='Revoke grant';revoke.disabled=!agentGrantsEditable||!grant.enabled;revoke.addEventListener('click',()=>{grant.enabled=false;renderAgentGrants();changedAgentGrants();agentGrantStatus('Grant disabled in draft. Publish after review to apply revocation.');});actions.append(generation,rotate,revoke);
|
||||
if(!agentGrantsPublishedIds.has(grant.id)){const remove=document.createElement('button');remove.type='button';remove.className='secondary';remove.textContent='Remove unpublished grant';remove.disabled=!agentGrantsEditable;remove.addEventListener('click',()=>{agentGrantsDraft=agentGrantsDraft.filter(g=>g!==grant);renderAgentGrants();changedAgentGrants();});actions.append(remove);}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<section id="user-profiles" class="card"><p class="eyebrow">IDENTITY AND FEATURE ACCESS</p><h2>User classes and website access</h2><p>Create profiles for any user class with your own names and controls. Employee, developer and high-protection templates are optional starting points. Assign profiles to verified enterprise subjects or explicit managed devices. Asset-owner labels are not authenticated identities. Website denials and category restrictions accumulate across matching profiles; an allowlist does not bypass a denial.</p><button id="profiles-refresh" class="secondary">Refresh profiles</button><p id="profiles-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Profile</th><th>Assignments</th><th>Developer tools</th><th>Action</th></tr></thead><tbody id="profiles-rows"></tbody></table></div><div class="row"><button id="profile-new" class="secondary">Create your own profile</button><label>Starting class<select id="profile-template"><option value="employee">Employee</option><option value="developer">Developer</option><option value="high-protection">High protection</option></select></label><button id="profile-use-template" class="secondary">Use class in draft</button></div><h3 id="profile-editor-title">Create profile</h3><div class="grid"><label>Identifier<input id="profile-id" maxlength="80"></label><label>Name<input id="profile-name" maxlength="100"></label><label>Verified enterprise subject identifiers<textarea id="profile-subjects" placeholder="Exact issuer-verified subject; one per line"></textarea></label><fieldset><legend>Explicit device assignments</legend><div id="profile-devices" class="device-checklist"></div></fieldset><label>Allowed website hosts<textarea id="profile-allowed" placeholder="Empty adds no allowlist restriction"></textarea></label><label>Denied website hosts<textarea id="profile-blocked" placeholder="*.unapproved.example"></textarea></label><fieldset><legend>Denied domain categories</legend><div id="profile-categories" class="device-checklist"></div></fieldset><label>Developer tools / browser console<select id="profile-devtools"><option value="inherit">Inherit baseline</option><option value="deny">Deny</option><option value="allow">Allow within delegated baseline</option></select></label><label>Downloads<select id="profile-downloads"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>AI workspace<select id="profile-ai"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>Local agents<select id="profile-agents"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label class="checks"><input id="profile-scan" type="checkbox"> Require document analysis</label></div><p class="muted">Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.</p><div class="row"><button id="profile-save">Publish profile</button><button id="profile-delete" class="danger" disabled>Delete profile</button></div><h3>Preview effective device policy</h3><div class="row"><select id="profile-preview-device" aria-label="Preview device"></select><button id="profile-preview" class="secondary">Preview current draft</button></div><p id="profile-preview-summary" aria-live="polite"></p><div id="profile-preview-diff"></div><details><summary>Central categories and developer delegation</summary><p>Categories contain your approved domain lists. This is not an automatic external website reputation feed. Enabling delegation permits reviewed developer-profile grants; denying profiles still win.</p><label class="checks"><input id="profile-delegation" type="checkbox"> Permit profiles to grant developer tools</label><h4>Website categories</h4><p>Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.</p><button id="profile-category-add" class="secondary">Add website category</button><button id="profile-category-reset" class="secondary">Discard category draft</button><p id="profile-category-status" role="status" aria-live="polite"></p><div id="profile-category-editor"></div><button id="profile-config-save">Publish categories and delegation</button></details><h3>Assignment visibility</h3><div class="table-wrap"><table><thead><tr><th>Device</th><th>Verified subject</th><th>Effective profiles</th></tr></thead><tbody id="profile-device-rows"></tbody></table></div></section><section class="card" id="policy-groups"><p class="eyebrow">CONTROLLED DEPLOYMENT</p><h2>Groups and pilot rollouts</h2><p>Apply additional restrictions to explicit device groups. Start with selected pilot devices, inspect their reported policy versions, then explicitly expand the cohort. Group rules can tighten the baseline; they cannot weaken it. No automatic promotion occurs.</p><button id="groups-refresh" classLine truncated
|
||||
<section class="card" id="bookmarks"><p class="eyebrow">COMPANY LIBRARY</p><h2>Managed bookmarks</h2><p>Publish intranet and approved service links to every browser. Company bookmarks are read-only for employees. Personal bookmarks remain separate; configure whether employees may add, edit or delete them.</p><div class="checks"><label><input type="checkbox" id="bookmarks-add"> Allow personal additions</label><label><input type="checkbox" id="bookmarks-edit"> Allow personal edits</label><label><input type="checkbox" id="bookmarks-delete"> Allow personal deletion</label><label><input type="checkbox" id="bookmarks-admin-read"> Allow administrators to read and edit personal bookmarks</label></div><fieldset><legend>AI library organization</legend><div class="checks"><label><input id="library-ai-enabled" type="checkbox"> Enable approved AI organization suggestions</label><label><input id="library-ai-autoapply" type="checkbox"> Permit agent application without an additional user review</label></div><p class="muted">Suggestions require the approved AI gateway and Library permissions. Agents also require the organization scopes below. Company bookmarks remain protected. Keep automatic application disabled to require explicit confirmation before changes.</p></fieldset><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="managed-bookmark-rows"></tbody></table></div><button class="secondary" id="managed-bookmark-add">Add company bookmark</button><p class="muted">Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.</p><h3>Device personal bookmarks</h3><p>Authorized administrators can review and remotely edit a device's synchronized personal bookmarks. Changes are applied when the browser next refreshes management state. Titles and URLs are never included in security audit events.</p><div class="row"><select id="bookmark-device" aria-label="Device to review"></select><button id="bookmark-device-load" class="secondary">Read device bookmarks</button></div><p id="bookmark-device-status" role="status" aria-live="polite"></p><div id="bookmark-device-editor" hidden><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="personal-bookmark-rows"></tbody></table></div><div class="row"><button id="personal-bookmark-add" class="secondary">Add personal bookmark</button><button id="bookmark-device-save">Save remote changes</button><button id="bookmark-device-refresh" class="secondary">Discard edits and refresh</button></div><p class="muted">Saving compares the loaded revision so another user's or administrator's updates cannot be overwritten silently.</p></div></section>
|
||||
<section id="teaching" class="card"><p class="eyebrow">TEACH YOUR BROWSER</p><h2>Define what must stay inside</h2><p>Add phrases that must never leave, or register an exact file fingerprint. Reference files stay on your computer; only their SHA-256 fingerprint enters policy.</p><div class="row"><input id="rule-label" placeholder="Rule name"><input id="rule-value" placeholder="Confidential phrase"><button id="add-rule">Add phrase</button></div><label class="file">Register a confidential file fingerprint<input id="fingerprint" type="file"></label><div id="rules"></div><div class="grid"><label class="wide">Try a policy example<textarea id="test-text" placeholder="Test content is sent to your management server, never to an AI provider."></textarea></label></div><button id="test" class="secondary">Test draft policy</button><output id="test-result"></output><h2>Local semantic reference protection</h2><p>Describe a confidential category. Your approved local EmbeddingGemma 2 runtime creates pinned text reference vectors. Document extraction and local OCR text can be compared against these references. This workflow does not train a model or provide native video inspection.</p><div class="grid"><label>Category name<input id="semantic-label" placeholder="Unreleased product designs"></label><label>Similarity threshold<input id="semantic-threshold" type="number" min="0.000001" max="1" step="0.01" value="0.8"></label><label class="wide">Confidential category description<textarea id="semantic-description" placeholder="Describe the protected topic with representative examples."></textarea></label></div><button id="semantic-add" class="secondary">Create semantic reference</button><label>Internal semantic reference document (up to 1 MiB)<input id="semantic-document" type="file"></label><button id="semantic-document-add" class="secondary">Derive local document reference</button><p>Only normalized reference vectors and coverage features enter the policy draft. No document, extracted text or model fine tuning is retained. Complete extraction and chunk coverage are required; publish explicitly to approve.</p><label class="checks"><input id="semantic-enabled" type="checkbox"> Enforce semantic categories</label><div id="semantic-rules"></div><p class="muted">Requires locally provisioned model weights, an exact model revision, matching dimensions and the document-v1 profile. Similarity is a detector score, not a confidence guarantee. Incomplete extraction or chunk coverage always blocks.</p></section>
|
||||
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-history:list" type="checkbox"> Read managed browsing history</label><label><input id="agent-scope-history:go" type="checkbox"> Navigate to a history entry</label><label><input id="agent-scope-window:state" type="checkbox"> Read browser window state</label><label><input id="agent-scope-window:control" type="checkbox"> Control browser window</label><label><input id="agent-scope-vault:status" type="checkbox"> Read password vault status</label><label><input id="agent-scope-vault:list" type="checkbox"> List masked references for approved origins</label><label><input id="agent-scope-vault:fill" type="checkbox"> Fill a selected credential on its exact origin</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><p class="wide" id="agent-navigation-window-help">History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.</p><p class="wide" id="agent-vault-help">Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.</p><div class="wide" id="agent-grants-controls"><h3>Individual agent identities<Line truncated
|
||||
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-history:list" type="checkbox"> Read managed browsing history</label><label><input id="agent-scope-history:go" type="checkbox"> Navigate to a history entry</label><label><input id="agent-scope-window:state" type="checkbox"> Read browser window state</label><label><input id="agent-scope-window:control" type="checkbox"> Control browser window</label><label><input id="agent-scope-sessions:save" type="checkbox"> Save managed tab sessions</label><label><input id="agent-scope-sessions:list" type="checkbox"> List saved session references</label><label><input id="agent-scope-sessions:restore" type="checkbox"> Restore an approved saved session</label><label><input id="agent-scope-vault:status" type="checkbox"> Read password vault status</label><label><input id="agent-scope-vault:list" type="checkbox"> List masked references for approved origins</label><label><input id="agent-scope-vault:fill" type="checkbox"> Fill a selected credential on its exact origin</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><p class="wide" id="agent-navigation-window-help">History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.</p><p class="wide" id="agent-sessions-help">Saving, listing and restoring sessions require separate capabilities. Saved sessions contain approved URLs and tab group metadata Line truncated
|
||||
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.</p><p>Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</label><label><input id="clipboard-cut" type="checkbox"> Allow cut</label><label><input id="clipboard-paste" type="checkbox"> Allow paste</label><label><input id="clipboard-command" type="checkbox"> Block suspicious execution commands</label></div><label>Maximum clipboard text bytes<input id="clipboard-max" type="number" min="1024" max="1048576"></label><p>Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.</p></section><section id="devices" class="card"><div class="section-head"><div><p class="eyebrow">DEVICE FLEET</p><h2>Enrollment and access</h2><p>Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.</p></div></div><div class="row"><input id="enroll-user" placeholder="Employee identity / asset owner"><button id="enroll">Create one-use enrollment code</button></div><output id="enrollment"></output><div class="row filter-row"><label>Find a device<input id="device-filter" type="search" placeholder="Name, identity or platform"></label><label>Access<select id="device-access-filter"><option value="all">All devices</option><option value="active">Active</option><option value="revoked">Revoked</Line truncated
|
||||
<section id="access-sessions" class="card"><p class="eyebrow">ADMINISTRATOR ACCESS</p><h2>Active management sessions</h2><p>Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.</p><button id="sessions-load" class="secondary">Refresh active sessions</button><p id="sessions-status" role="status" aria-live="polite">Session inventory requires the administrator role.</p><div class="table-wrap"><table><thead><tr><th>Identity</th><th>Role</th><th>Expires</th><th>Action</th></tr></thead><tbody id="session-rows"></tbody></table></div></section><section id="incidents" class="card"><p class="eyebrow">SECURITY OPERATIONS</p><h2>Incident triage</h2><p>Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.</p><div class="row filter-row"><label>Status<select id="incident-status-filter"><option value="">All statuses</option><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity-filter"><option value="">All severities</option><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><button id="incidents-refresh" class="secondary">Refresh incidents</button></div><p id="incidents-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Created</th><th>Security event</th><th>Status</th><th>Severity</th><th>Assignee</th><th>Review</th></tr></thead><tbody id="incident-rows"></tbody></table></div><button id="incidents-more" class="secondary" hidden>Load older incidents</button><div id="incident-detail" hidden><div class="section-head"><h3 id="incident-title">Incident</h3><button id="incident-detail-refresh" class="secondary">Discard edits and refresh</button></div><p id="incident-revision" class="muted"></p><pre id="incident-event"></pre><section id="incident-evidence" class="evidence-viewer" hidden aria-labelledby="evidence-title"><div class="section-head"><h3 id="evidence-title">Device-reported protected evidence</h3><button id="evidence-close" class="secondary">Close and clear</button></div><label>Access purpose<select id="evidence-reason"><option value="">Choose a purpose</option><option value="incident-response">Incident response</option><option value="investigation">Investigation</option><option value="compliance">Compliance</option></select></label><div id="evidence-rows"></div><p id="evidence-status" role="status" aria-live="polite"></p><pre id="evidence-plaintext" hidden></pre></section><div class="grid"><label>Investigation status<select id="incident-state"><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity"><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><label>Assignee<input id="incident-assignee" maxlength="160" placeholder="Team or analyst identity"></label><label class="wide">Investigation note<textarea id="incident-comment" maxlength="1000" placeholder="Metadata and investigation outcome only; do not paste secrets."></textarea></label></div><button id="incident-save">Save investigation update</button><p id="incident-detail-status" role="status" aria-live="polite"></p><h3>Investigation history</h3><div id="incident-history"></div></div></section><section class="card" id="audit-retention"><p class="eyebrow">AUDIT LIFECYCLE</p><h2>Database event retention</h2><p id="audit-retention-status" role="status">Loading deployment configuration.</p><p class="muted">Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.</p></section><section id="events" class="card"><p class="eyebrow">SECURITY MONITORING</p><h2>Recent audit events</h2><div class="row"><button id="export-ecs" class="secondary">Export ECS JSON</button><button id="export-cef" class="secondary">Export CEF</button></div><div class="row filter-row"><label>Find an event<input id="event-filter" type="search" placeholder="Event type, device or metadata"></label><label>Decision<select id="event-decision-filter"><option value="all">All events</option><option value="block">Blocks</option><option value="allow">Allows</option></select></label><output id="event-filter-count" aria-live="polite"></output></div><div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Device</th><th>Metadata</th></tr></thead><tbody id="event-rows"></tbody></table></div><p class="muted">This view shows the latest 100 eventsLine truncated
|
||||
<section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Protected evidence collection</h2><p>Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label><label><input id="evidence-policy-interactions" type="checkbox"> Capture clipboard, agent-click and observed page-gesture metadata</label></div><p>Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.</p><p>Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, action and bounded target metadata. Guarded clipboard and agent effects are marked completed; trusted foreground page clicks and activated form events are marked observed, which does not prove network transmission. Sensitive forms, page paths, values, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Collection is bounded and limited to the active main-frame page; it is not a complete replay. Publish and review this draft through the normal policy workflow.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diaLine truncated
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Managed saved sessions for local agents
|
||||
|
||||
Administrators separately opt into `sessions:save`, `sessions:list` and
|
||||
`sessions:restore` in the signed baseline and individual grant. All three controls
|
||||
start unchecked. These are separate from `sessions`, which inventories currently
|
||||
open native tabs. Existing pairing, expiry, generation, destination, feature and
|
||||
user-profile restrictions still apply. Saving browser tabs never preserves or
|
||||
reactivates agent authority.
|
||||
|
||||
| Action | Purpose |
|
||||
| --- | --- |
|
||||
| `sessions:save` | Save selected native tab IDs as a named collection, optionally replacing an owned collection with its current revision. |
|
||||
| `sessions:list` | Inspect the current actor's approved saved collections and storage revision. |
|
||||
| `sessions:restore` with collection ID | Prepare a short-lived restoration plan without opening tabs. |
|
||||
| `sessions:restore` with returned plan ID | Consume the plan once and restore its approved destinations. |
|
||||
|
||||
The local collection store accepts at most 16 collections and 32 tabs per
|
||||
collection. Its revision prevents stale replacement. Collection ownership binds
|
||||
to the enrolled device and verified actor; ownership includes the actor's credential generation. Rotation makes old-generation
|
||||
collections inaccessible; no implicit migration restores them. Collections belong to the local device, not a central cross-device
|
||||
sync service. Windows DPAPI encrypts the stored bytes before atomic replacement.
|
||||
Plaintext buffers are cleared. Corrupt files, hard links and unexpected file types
|
||||
fail closed.
|
||||
|
||||
Only collection name, approved HTTP(S) URLs, titles, optional group metadata and
|
||||
active-tab index belong in a saved collection. No cookies, authentication tokens,
|
||||
passwords, form values, navigation history or serialized Chromium page state are
|
||||
accepted. Benign query strings and fragments are preserved. URL userinfo, recognized
|
||||
authentication paths, authentication query/fragment parameters and credential-shaped
|
||||
metadata are rejected rather than silently rewritten.
|
||||
Names, titles and URLs also pass current local/enterprise DLP before saving or
|
||||
exporting. Unavailable or incomplete inspection blocks the operation.
|
||||
|
||||
Restoration plans expire after 60 seconds, are bounded, and are consumed before
|
||||
use. Policy changes, actor/generation changes, cancellation, changed storage
|
||||
revision and changed collections invalidate plans. Preparing and applying both
|
||||
check current destinations and classify saved metadata. The native implementation preserves existing tabs, applies current navigation
|
||||
enforcement to new tabs, and closes only the tabs created by a failed restoration.
|
||||
Closing failed restoration tabs cannot undo network requests already sent. Restoring URLs does not
|
||||
restore login sessions; a website may require normal authentication again.
|
||||
|
||||
Do not retry a mutating action merely because its response or diagnostic export
|
||||
failed. Check current tabs and collections first. Security audit and OpenTelemetry
|
||||
are separate from saved collection contents; diagnostics should contain fixed
|
||||
operation names and timings, never saved URLs, titles or collection names.
|
||||
|
||||
## Verification status
|
||||
|
||||
The adapter and encrypted-store tests cover classification failures, one-use
|
||||
plans, policy/generation/revision changes, ciphertext alteration, ownership and
|
||||
structural bounds. Central admin controls are implemented and tested as explicit
|
||||
opt-in. The actual Windows/Electron admin fixture verified all three scopes in
|
||||
baseline and individual grants, separate-author review, self-approval denial,
|
||||
auditor read-only controls, rotation and policy-history restoration. Its 154
|
||||
completed API requests exported parent-correlated traces; three GET requests
|
||||
canceled during identity reload are recorded separately and no policy mutation
|
||||
failed (`artifacts/agent-grants-history-admin-proof.json`).
|
||||
|
||||
The actual browser fixture runs two Windows/Electron processes against the same
|
||||
enrolled device and local profile. It saves two tabs with benign query strings,
|
||||
fragments and group metadata, then verifies encrypted storage survives restart.
|
||||
Private decryption inside the isolated fixture checks exact metadata keys without
|
||||
printing plaintext. Edited password/form values and renderer history state are
|
||||
absent; restored pages receive their fresh server defaults. Existing tabs remain
|
||||
and restored groups are verified. Cross-actor plan use, replay and stale revision
|
||||
are denied. After signed central disable, host restriction and targeted revocation
|
||||
reach the browser, a refused commit sends no additional navigation request.
|
||||
Legitimate policy-triggered page reloads are outside that post-policy measurement.
|
||||
|
||||
A controlled delayed HTTPS receiver also proves an actual away-and-back activation
|
||||
race: the first restored tab begins navigation, the trusted UI activates an
|
||||
original tab and then the new tab, and restoration fails. Only newly created tabs
|
||||
are removed, existing tabs survive, and the second saved destination is never
|
||||
requested. The first already-started request cannot be undone. Actual trace
|
||||
`583a1d4dbc80fb2cbcb135b1622a4993` records native work (1410.0137 ms), rollback
|
||||
(0.7242 ms) and the failed restoration (1412.796 ms).
|
||||
|
||||
Actual exported save/list/restore traces contain correlated named inspection,
|
||||
storage, preflight and native stages, plus all five audit serialization/encryption/
|
||||
write/fsync/commit stages beneath their audit parent. Proof is in
|
||||
`artifacts/session-agent-controls-native-proof.json`. These proofs do not establish
|
||||
production key custody, cross-device session sync or a complete browser backup.
|
||||
@@ -108,3 +108,34 @@ distinct parents and complete evidence phase correlation. Trace
|
||||
`62e09413b9a42f4a58bd7f99485176ac` and
|
||||
`545ddac025113e50effed4e9a2ac87ee` each also have 17 spans. Collector rejected writes
|
||||
were zero. These are local reproduction timings, not throughput guarantees.
|
||||
|
||||
On 2026-10-13, the running protected collector and the actual exported trace
|
||||
`fd47bcfc8b259712101ad3c81c1d95c1` were inspected again. The request took
|
||||
724.3133 ms, its first audit phase 682.6577 ms, and dispatch 2.0827 ms.
|
||||
Its correlated audit delivery took 52.7383 ms. The audit phase alone does not
|
||||
establish whether serialization, DPAPI, filesystem synchronization or scheduling
|
||||
caused the delay. New synchronous persistence spans separate `audit.serialize`,
|
||||
`audit.encrypt`, `audit.write`, `audit.fsync` and `audit.commit`, without changing
|
||||
the synchronous persistence boundary or retrying a write. Only fixed operation
|
||||
identifiers and timings enter diagnostic attributes; no audit payload or paths.
|
||||
Previously collected traces cannot supply these newly added stages.
|
||||
|
||||
The subsequent actual Electron vault fixture passed with the added stages.
|
||||
Trace `e98cac3e18c6bd7b0e0442f033a7fa2d` exports all five persistence
|
||||
children under `agent.audit`: serialization 0.0544 ms, encryption 0.0501 ms,
|
||||
write 7.2461 ms, fsync 2.3703 ms and close/rename 14.7423 ms. These timings
|
||||
describe this new run; they do not resolve the previous 683 ms observation.
|
||||
The five-stage parentage was checked against the live collector for both list
|
||||
and fill requests, with zero rejected writes. Twelve existing diagnostics tests
|
||||
and four dedicated real OTLP tests pass, including central disable, secret
|
||||
redaction, synchronous return/throw preservation and no replay on instrumentation
|
||||
failure. At that stage, session adapter exports were proven in a fixture;
|
||||
native browser restoration required the subsequent integration proof below.
|
||||
|
||||
The actual two-process Windows session proof now exports save/list/restore stages
|
||||
and their audit persistence children. A delayed HTTPS receiver also verifies
|
||||
that manually switching away and back aborts restoration, preserves existing tabs
|
||||
and never requests the second saved destination. Trace
|
||||
`583a1d4dbc80fb2cbcb135b1622a4993` records the failed native stage at 1410.0137 ms
|
||||
and owned-tab rollback at 0.7242 ms. The delay was intentional fixture behavior;
|
||||
it is not a production performance result. Collector rejected writes were zero.
|
||||
@@ -1,5 +1,20 @@
|
||||
# S Browser: Anforderungsabgleich und offene Arbeit
|
||||
|
||||
## Aktuelle nächste Abnahmen
|
||||
|
||||
Die gespeicherte Agent-Sitzung ist inzwischen nativ integriert; die detaillierten
|
||||
Nachweise stehen in `docs/agent-session-controls.md`. Der volle Produktumfang bleibt
|
||||
offen. Als nächste Implementierungsarbeiten gelten sicher geprüfter visueller
|
||||
Agent-Export, legitime kontrollierte Credential-Übertragung und nachvollziehbare
|
||||
Ownership für Hintergrund-Worker. Danach folgen breitere lokale Medienprüfung und
|
||||
Analysten-Evidence-Flows. Jede neue Freigabe braucht aktuelle Policy-/Identitäts-
|
||||
Prüfungen sowie einen echten Receiver-/Browser-Nachweis und korrelierte Traces.
|
||||
|
||||
Produktionsabnahmen bleiben separat: signierter Installer/Updatekanal, unabhängige
|
||||
Sicherheitsprüfung, Geräteschlüssel/Enrollment-Vertrauen und Rotation, getestete
|
||||
Wiederherstellung/HA sowie tatsächliche Kunden-IdP-, ICAP-/SIEM-/Netzwerkintegrationen.
|
||||
Mock- und lokale Fixtures schließen diese externen Gates nicht.
|
||||
|
||||
|
||||
|
||||
Stand: 2026-10-13. Diese Prüfung vergleicht die Anforderungen aus dem Chat mit vorhandenem Quellcode und benannten Testfällen. Ein vorhandener Test ist ein begrenzter Nachweis für sein Szenario, kein Sicherheitszertifikat. Laufende Änderungen anderer Agenten müssen nach Integration erneut geprüft werden.
|
||||
@@ -64,7 +79,8 @@ Windows zuerst: einen zentral verwalteten Browser mit lokaler Inhaltsprüfung, f
|
||||
|
||||
| KI als First Class, kontrolliertes Gateway | Teilweise | `apps/server/app.mjs`, `inspection.mjs`, Browser-AI-UI, `tests/enterprise-gateway.test.mjs`: freigegebene lokale Ollama/kompatible Provider, serverseitige Credentials, Prompt-/Outputprüfung. Kein vollständiges Berechtigungs-RAG, Modellinventar, Provider-Tenantnachweis oder umfassendes KI-Tool-Autorisierungsmodell. |
|
||||
|
||||
| Lokale Agenten steuern Sessions | Umgesetzt für begrenzte kryptografisch geschützte API | `apps/browser/agent-control.cjs`, `tests/agent-control.test.mjs`, `tests/agent-adapters.test.mjs`, `scripts/agent-client.cjs`: Loopback, DPAPI-Pairing, zentrale Scopes und feste Aktionen; ML-KEM-1024/AES-256-GCM, gepinnte Ed25519/ML-DSA-87-Challenge, HMAC-SHA384-Requestbindung und Replay-Abwehr. Kein freies JS/CDP, Passwort-/Cookieexport. Stabile native Tab-UUIDs, Kontextbindung sowie explizite App-/Einstellungs-/Bookmark-/Tab-Rechte sind ergänzt und mit echtem CLI geprüft. Freigaben bleiben zentral begrenzt. Inspect/Scroll und tabgebundene Seitenaktionen sind ergänzt; sieben Agent-Phasen mit 124 tatsächlich exportierten Timing-Spans in `artifacts/agent-pq-trace-proof.json`. Vollständige UI-/History-/Screenshot-/Formularsteuerung und individuelle delegierte Agent-Identitäten bleiben offen. |
|
||||
| Lokale Agenten steuern Sessions | Teilweise; geschützte unterstützte Aktionen geprüft | Individuelle zentral genehmigte Identitäten, Generation/Expiry, Zielhostbindung, DPAPI-Pairing, ML-KEM-1024/AES-GCM und gepinnte Ed25519/ML-DSA-87-Challenges, HMAC/Replay-Abwehr. Native Tab-UUIDs, Seiteninteraktion, Inspektion, Bookmarks, Appearance, gruppierte Tabs, geprüfte History-Navigation und begrenzte Fenstersteuerung sowie menschenentsperrtes Vault-Autofill implementiert. `docs/individual-agent-grants.md`, `docs/agent-history-window.md`, `docs/agent-vault-controls.md`, jeweilige echte Native-Proofs. Sichere Screenshots, Datei-/Vault-Mutationen, umfassende Widgets und attribuierte Worker-Steuerung bleiben offen; keine freie JS/CDP- oder Credential-Export-API. |
|
||||
| Verschlüsselte gespeicherte Sitzungen | Native Integration und finales Windows-Paket geprüft | `session-snapshot.cjs`, `agent-session-adapters.cjs`, `session-restore.cjs`, `docs/agent-session-controls.md`: benannte lokale URL/Titel/Gruppen-Sammlungen, Gerät/Principal/Generation-Bindung, DLP vor Save/List/Restore, CAS und einmalige Restore-Pläne. Echter Zwei-Prozess-Windows-Proof mit erhaltenen bestehenden Tabs; keine Formulare, Passwörter oder pageState. Kein Cross-Device-Sync oder vollständiges Session-Backup. |
|
||||
|
||||
| Passwortmanager mit Post-Quantum-Kryptografie | Teilweise | `apps/browser/vault.cjs`, `password-fill.cjs`, `tests/vault.test.mjs`, `tests/password-fill.test.mjs`: ML-KEM-1024/AES-GCM/scrypt und Windows-DPAPI, Idle-/Blur-Lock, gebundene HTTPS-Formfüllung. Kein Sync/Recovery/Passkeys, keine unabhängige Kryptografieprüfung oder FIPS-Modulvalidierung. |
|
||||
|
||||
@@ -650,3 +666,12 @@ The native tab/DLP/revocation proof passes. Logs:
|
||||
`artifacts/vault-agent-final-windows-build.log`, `vault-agent-final-package-proof.log`,
|
||||
`vault-agent-native-tabs.log`, `vault-agent-syntax.log`.
|
||||
The executable remains unsigned; no signed production installer is claimed.
|
||||
|
||||
|
||||
## Gespeicherte Agent-Sitzungen und Debug-Stufen, verifizierter Stand
|
||||
|
||||
624 Tests bestehen. Der echte Zwei-Prozess-Windows/Electron-Test speichert lokale DPAPI-geschuetzte Sammlungen, prueft Metadaten privat nach Entschluesselung und stellt URLs/Titel/Gruppen als neue verwaltete Tabs wieder her. Formular-, Passwort- und Chromium-pageState-Werte werden nicht gespeichert. Ein verzoegertes HTTPS-Ziel prueft den manuellen Tabwechsel weg und zurueck: Abbruch, nur eigene Tabs geschlossen, kein zweites gespeichertes Ziel gesendet. Dies beweist keinen Rueckruf bereits gesendeter Requests.
|
||||
|
||||
Die zentrale Admin-UI verifiziert separate standardmaessig ausgeschaltete Rechte, unabhaengige Freigabe, Auditor-Read-only und Policy-History-Restore. 154 abgeschlossene API-Requests exportierten korrelierte Traces; drei abgebrochene GETs sind getrennt dokumentiert. Save/List/Restore und Audit-Serialisierung/Verschluesselung/Write/fsync/Commit exportieren benannte echte OTLP-Stufen. Die fruehere 683-ms-Audit-Beobachtung bleibt ursachenseitig ungeklaert; neue Traces ersetzen keine fehlende historische Messung.
|
||||
|
||||
Finaler stabiler Windows-Build und exakter Paketvergleich bestehen: artifacts/session-release-windows-build.log und artifacts/session-final-package-proof.log. Der erste Build wurde wegen geaenderter Quellen verworfen; nur der finale stabile Build gilt. Das Executable ist weiterhin NotSigned. Native Proofs: artifacts/session-agent-controls-native-proof.json und artifacts/agent-grants-history-admin-proof.json. Vollstaendige Produkt-/Produktionsabnahme bleibt offen.
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
const {z}=require('zod');
|
||||
const application=['vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
const application=['sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
const AGENT_SCOPES=Object.freeze({sessions:'sessions:list',navigate:'browser:navigate',back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate',read:'browser:read',inspect:'browser:read',scroll:'browser:interact',click:'browser:interact',type:'browser:interact',select:'browser:interact',check:'browser:interact',ai:'ai:request',...Object.fromEntries(application.map(action=>[action,action])),'agent-request':'agent:request'});
|
||||
const AgentAuditSchema=z.object({action:z.enum(Object.keys(AGENT_SCOPES)),scope:z.enum([...new Set(Object.values(AGENT_SCOPES))]),outcome:z.enum(['authorized','completed','denied','failed']),sessionId:z.string().uuid().optional(),principalId:z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/).optional(),credentialGeneration:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional()}).strict().superRefine((value,ctx)=>{
|
||||
if((value.principalId!==undefined)!==(value.credentialGeneration!==undefined))ctx.addIssue({code:'custom',message:'Agent principal attribution requires both identity and generation'});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { isIP } from 'node:net';
|
||||
export const AgentScopes=['vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
export const AgentScopes=['sessions:save','sessions:list','sessions:restore','vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','sessions','navigate','back','forward','reload','read','inspect','scroll','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
const host=z.string().max(253).regex(/^(\*\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i);
|
||||
export const VisualOriginSchema=z.string().max(2048).refine(value=>{try{const u=new URL(value);return u.protocol==='https:'&&!u.username&&!u.password&&!u.search&&!u.hash&&u.origin===value&&!u.hostname.includes('*')&&!u.hostname.endsWith('.')&&!isIP(u.hostname.replace(/^\[|\]$/g,''))&&/^[a-z0-9.-]+$/.test(u.hostname);}catch{return false;}},'Expected a canonical exact HTTPS domain origin');
|
||||
export const VisualViewportSchema=z.object({width:z.number().int().min(16).max(8192),height:z.number().int().min(16).max(8192)}).strict();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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});}
|
||||
@@ -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);}});
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
|
||||
@@ -14,3 +14,5 @@ test('download scan settings remain explicit and cannot alter upload policy',()=
|
||||
test('new history and window capability controls require explicit opt-in and explain limits',()=>{for(const scope of ['history:list','history:go','window:state','window:control']){const tag=html.match(new RegExp(`<input[^>]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}assert.ok(html.includes('History read and history navigation are separate capabilities'));assert.ok(html.includes('never grants OS or administrator access'));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');assert.ok(editor.includes('enabled:false'));assert.ok(editor.includes('scopes:[]'));assert.ok(editor.includes('check.disabled=!agentGrantsEditable'));});
|
||||
|
||||
test('vault agent capabilities start unchecked and describe human-unlocked exact-origin access',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);for(const scope of ['vault:status','vault:list','vault:fill']){assert.ok(scopes.includes(scope));const tag=html.match(new RegExp(`<input[^>]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}for(const text of ['A person must unlock the local vault','agents cannot unlock it or export passwords',"credential's exact origin"])assert.ok(html.includes(text));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');for(const text of ['human unlock','Agents cannot unlock the vault or export passwords'])assert.ok(editor.includes(text));for(const unsupported of ['vault:unlock','vault:export','vault:mutate'])assert.ok(!scopes.includes(unsupported));});
|
||||
|
||||
test('session persistence capabilities are explicit opt-in with no credential state restoration',()=>{const source=readFileSync(new URL('../apps/server/public/admin.js',import.meta.url),'utf8'),scopes=vm.runInNewContext(source.match(/const agentScopes=(\[[^;]+\]);/)[1]);for(const scope of ['sessions:save','sessions:list','sessions:restore']){assert.ok(scopes.includes(scope));const tag=html.match(new RegExp(`<input[^>]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}for(const text of ['Saving, listing and restoring sessions require separate capabilities','no passwords, form values or page state','never restores revoked agent authority'])assert.ok(html.includes(text));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');assert.ok(editor.includes('Restoration rechecks current policy'));});
|
||||
@@ -0,0 +1,14 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';const require=createRequire(import.meta.url);const {createAgentSessionAdapters,safeSessionTab}=require('../apps/browser/agent-session-adapters.cjs');
|
||||
function fixture(){let revision='r1',saved=null,decision={decision:'allow',coverage:{complete:true}},restores=0;const policy={version:1,agents:{enabled:true,scopes:['sessions:save','sessions:list','sessions:restore'],allowedHosts:['example.com']}};const c={ready:true,policy,sessionId:'tab1',token:'token',actor:{agentId:'agent1',generation:1},webContents:{getURL:()=> 'https://example.com/docs'},destinationAllowed:()=>true};const store={list:()=>({revision,collections:saved?[saved]:[]}),save:(value,opt)=>{assert.equal(opt.expectedRevision,revision);saved={id:'id1',...value};revision='r2';return {id:saved.id,revision};},load:()=>({revision,...saved})};const adapters=createAgentSessionAdapters({getContext:()=>c,getSessions:()=>[{id:'tab1',title:'Public docs'}],contextCurrent:()=>true,getOwner:()=>({deviceId:'device1',ownerId:c.actor.agentId}),store,inspect:async()=>decision,restore:async(tabs,opt)=>{opt.assertCurrent();restores++;return {completed:true,tabCount:tabs.length};}});return {adapters,c,store,setDecision:v=>decision=v,setRevision:v=>revision=v,restores:()=>restores};}
|
||||
test('session collections classify before storage, restore requires one-use plan',async()=>{const f=fixture();await f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']});assert.equal((await f.adapters['sessions:list']()).collections.length,1);const plan=await f.adapters['sessions:restore']({id:'id1'});assert.equal(f.restores(),0);assert.equal((await f.adapters['sessions:restore']({planId:plan.planId})).completed,true);await assert.rejects(f.adapters['sessions:restore']({planId:plan.planId}));assert.equal(f.restores(),1);});
|
||||
test('incomplete and blocked classification fails closed before store/export',async()=>{for(const verdict of [{decision:'block'},{decision:'allow',coverage:{complete:false}},{decision:'allow',ruleId:'timeout'}]){const f=fixture();f.setDecision(verdict);await assert.rejects(f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']}));assert.equal(f.store.list().collections.length,0);}});
|
||||
test('changed generation and changed storage invalidate restore plans',async()=>{for(const mutate of [f=>f.c.actor.generation++,f=>f.setRevision('r3'),f=>f.c.policy={...f.c.policy}]){const f=fixture();await f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']});const p=await f.adapters['sessions:restore']({id:'id1'});mutate(f);await assert.rejects(f.adapters['sessions:restore']({planId:p.planId}));assert.equal(f.restores(),0);}});
|
||||
test('session metadata rejects auth pages, URL secrets and arbitrary state',()=>{for(const url of ['https://example.com/login','https://example.com/oauth/callback','https://example.com/docs?token=x','https://example.com/#code=secret','file:///tmp/a','https://user:[email protected]/','https://example.com/%70assword/reset'])assert.throws(()=>safeSessionTab({url,title:'Public'}));assert.throws(()=>safeSessionTab({url:'https://example.com/',title:'password=secret'}));});
|
||||
test('session scopes default off and cancellation denies operations',async()=>{const f=fixture();f.c.policy.agents.scopes=[];await assert.rejects(f.adapters['sessions:list']());f.c.policy.agents.scopes=['sessions:list'];await assert.rejects(f.adapters['sessions:list']({}, {signal:AbortSignal.abort()}));});
|
||||
test('changed native target while DLP awaits prevents snapshot persistence',async()=>{const f=fixture();let moved=false;f.c.webContents.getURL=()=>moved?'https://example.com/changed':'https://example.com/docs';const original=f.store.list;f.store.list=()=>{moved=true;return original();};await assert.rejects(f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']}));assert.equal(original().collections.length,0);});
|
||||
test('changing stored revision during final restore inspection prevents release',async()=>{const f=fixture();await f.adapters['sessions:save']({name:'Work',sessionIds:['tab1']});const p=await f.adapters['sessions:restore']({id:'id1'});const load=f.store.load;f.store.load=()=>{const value=load();f.setRevision('r3');return value;};await assert.rejects(f.adapters['sessions:restore']({planId:p.planId}));assert.equal(f.restores(),0);});
|
||||
test('metadata never persists page state or credential-shaped collection names',async()=>{const f=fixture();await assert.rejects(f.adapters['sessions:save']({name:'access_token=abcd',sessionIds:['tab1']}));assert.throws(()=>safeSessionTab({url:'https://example.com/',title:'Public',pageState:'credential'}));});
|
||||
|
||||
test('benign app search and fragments retain full target while encoded credential parameters fail',()=>{const url='https://example.com/docs?project=public&page=2#chapter-3';assert.equal(safeSessionTab({url,title:'Docs'}).url,url);for(const bad of ['https://example.com/docs?%2574oken=x','https://example.com/docs#sid=abcd','https://example.com/%256cogin'])assert.throws(()=>safeSessionTab({url:bad,title:'Docs'}));});
|
||||
test('session save preserves groups and detects asynchronous group changes',async()=>{const f=fixture();let group='Research';f.c.webContents.getURL=()=> 'https://example.com/docs';const original=f.store.list;f.store.list=()=>{group='Changed';return original();};const {createAgentSessionAdapters}=require('../apps/browser/agent-session-adapters.cjs');const adapter=createAgentSessionAdapters({getContext:()=>f.c,getSessions:()=>[{id:'tab1',title:'Public docs',group}],contextCurrent:()=>true,getOwner:()=>({deviceId:'device1',ownerId:'agent1'}),store:f.store,inspect:async()=>({decision:'allow'}),restore:async()=>({completed:true})});await assert.rejects(adapter['sessions:save']({name:'Work',sessionIds:['tab1']}));assert.equal(original().collections.length,0);f.store.list=original;await adapter['sessions:save']({name:'Work',sessionIds:['tab1']});assert.equal(original().collections[0].tabs[0].group,'Changed');});
|
||||
test('native restore can deliberately change activation but source navigation remains bound',async()=>{for(const changeNavigation of [false,true]){const f=fixture();let activation=1,navigation=4;f.c.token={foreground:true,activation:1,navigationId:4,url:'https://example.com/docs',tabId:'tab1'};const adapter=createAgentSessionAdapters({getContext:()=>f.c,getSessions:()=>[{id:'tab1',title:'Public docs'}],contextCurrent:t=>t.navigationId===navigation&&(!t.foreground||t.activation===activation),getOwner:()=>({deviceId:'device1',ownerId:'agent1'}),store:f.store,inspect:async()=>({decision:'allow'}),restore:async(_tabs,{assertCurrent})=>{activation++;if(changeNavigation)navigation++;assertCurrent();return {completed:true};}});await adapter['sessions:save']({name:'Work',sessionIds:['tab1']});const p=await adapter['sessions:restore']({id:'id1'});if(changeNavigation)await assert.rejects(adapter['sessions:restore']({planId:p.planId}));else assert.equal((await adapter['sessions:restore']({planId:p.planId})).completed,true);}});
|
||||
@@ -0,0 +1,9 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import crypto from 'node:crypto';import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url),{startAgentControl}=require('../apps/browser/agent-control.cjs'),{requestAgent}=require('../apps/browser/agent-transport-client.cjs');
|
||||
const id=()=>crypto.randomUUID();
|
||||
async function fixture(t,actions,scopes=['sessions:save','sessions:list','sessions:restore']){const calls=[],events=[],expiresAt=new Date(Date.now()+60000).toISOString();const api=await startAgentControl({principalMode:'individual',principals:[{id:'session.agent',generation:1,enabled:true,expiresAt,scopes}],authorize:async({scope})=>({allowed:scopes.includes(scope),role:'agent',scopes}),actions:Object.fromEntries(Object.entries(actions).map(([action,fn])=>[action,async(args,context)=>{calls.push({action,args,principalId:context.principalId,generation:context.generation});return fn(args,context);} ])),audit:async e=>events.push(e)});t.after(()=>api.close());const credential=api.issuePrincipal('session.agent',1,expiresAt);return {calls,events,send:async(action,args={})=>{const response=await requestAgent(credential,{action,args});return {status:response.status,...await response.json()};}};}
|
||||
test('protected session requests dispatch only canonical arguments and exact opaque save response',async t=>{const collection=id(),revision=id(),tab=id();const f=await fixture(t,{'sessions:save':()=>({id:collection,revision,tabCount:1,password:'must-not-release',pageState:'private'})});const result=await f.send('sessions:save',{name:'Research',sessionIds:[tab],id:collection,expectedRevision:revision});assert.equal(result.status,200);assert.deepEqual(result.result,{id:collection,revision,tabCount:1});assert.deepEqual(f.calls[0].args,{name:'Research',sessionIds:[tab],id:collection,expectedRevision:revision});assert.equal(f.calls[0].principalId,'session.agent');});
|
||||
test('protected transport rejects arbitrary persistence fields and ambiguous restoration stages before dispatch',async t=>{const collection=id(),plan=id(),f=await fixture(t,{'sessions:save':()=>({}),'sessions:restore':()=>({}),'sessions:list':()=>({})});for(const args of [{name:'Research',sessionIds:[id()],pageState:'private'},{name:'Research',sessionIds:[id()],id:'forged'},{name:'Research',sessionIds:[]},{name:'Research',sessionIds:Array(33).fill(id())}])assert.equal((await f.send('sessions:save',args)).status,400);for(const args of [{},{id:collection,planId:plan},{id:collection,url:'https://evil.test/'},{planId:'forged'}])assert.equal((await f.send('sessions:restore',args)).status,400);assert.equal((await f.send('sessions:list',{ownerId:'other-agent'})).status,400);assert.equal(f.calls.length,0);});
|
||||
test('protected two-stage restore returns bounded plans then native IDs, never serialized state',async t=>{const collection=id(),plan=id(),tab=id();const f=await fixture(t,{'sessions:restore':args=>args.id?{id:collection,planId:plan,tabCount:1,pageState:'private'}:{completed:true,sessionIds:[tab],activeSessionId:tab,contents:{password:'private'},url:'https://private.test'}});assert.deepEqual((await f.send('sessions:restore',{id:collection})).result,{id:collection,planId:plan,tabCount:1});assert.deepEqual((await f.send('sessions:restore',{planId:plan})).result,{completed:true,sessionIds:[tab],activeSessionId:tab});});
|
||||
test('protected collection inventory validates destinations and rejects pageState rather than leaking adapter objects',async t=>{const collection=id(),revision=id();let row={url:'https://example.test/search?q=research#results',title:'Research',group:'Work'};const f=await fixture(t,{'sessions:list':()=>({revision,collections:[{id:collection,name:'Research',activeIndex:0,tabs:[row],ownerId:'never-release'}],password:'never-release'})});assert.deepEqual((await f.send('sessions:list')).result,{revision,collections:[{id:collection,name:'Research',activeIndex:0,tabs:[row]}]});for(const bad of [{...row,pageState:'private'},{...row,url:'https://example.test/?token=secret'},{...row,url:'https://user:[email protected]/'},{...row,title:'api_key=secretvalue'}]){row=bad;const result=await f.send('sessions:list');assert.notEqual(result.status,200);assert.equal(JSON.stringify(result).includes('secretvalue'),false);}});
|
||||
test('protected session scopes are individually denied and malformed native IDs never release',async t=>{const f=await fixture(t,{'sessions:save':()=>({id:id(),revision:id(),tabCount:1}),'sessions:restore':()=>({completed:true,sessionIds:['invalid'],activeSessionId:'invalid'})},['sessions:restore']);assert.equal((await f.send('sessions:save',{name:'Research',sessionIds:[id()]})).status,403);assert.equal(f.calls.length,0);assert.notEqual((await f.send('sessions:restore',{planId:id()})).status,200);});
|
||||
@@ -0,0 +1,16 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';const require=createRequire(import.meta.url),{restoreNativeSession}=require('../apps/browser/session-restore.cjs');
|
||||
function fixture(count=1){let active='old0',activation=1,revision='r1',authority=true,creates=0;const records=Array.from({length:count},(_,i)=>({id:'old'+i,contents:{isDestroyed:()=>false}})),removed=[],stages=[];const registry={all:()=>records,active:()=>records.find(r=>r.id===active),context:()=>({activation}),get:id=>{const row=records.find(r=>r.id===id);if(!row)throw Error('Unknown tab');return row;},remove:id=>{removed.push(id);records.splice(records.findIndex(r=>r.id===id),1);if(active===id)active=records[0]?.id;}};const options={activeIndex:0,assertCurrent:()=>{if(!authority)throw Error('Authority revoked');},revision:'r1',owner:{deviceId:'device',ownerId:'owner'}},deps={registry,store:{list:()=>({revision})},activate:id=>{if(active!==id){active=id;activation++;}},traceOperation:async(name,fn)=>{stages.push(name);return fn();},create:async(_input,controls)=>{creates++;controls.assertCurrent();const native={id:'new'+creates,contents:{isDestroyed:()=>false}};records.push(native);controls.onCreated(native);controls.assertCurrent();if(active!==native.id){active=native.id;activation++;}controls.onActivated(native);controls.assertCurrent();return native.id;}};return {records,removed,stages,options,deps,setRevision:r=>revision=r,revoke:()=>authority=false,creates:()=>creates,active:()=>active};}
|
||||
const tabs=[{url:'https://example.com/docs',title:'Docs',group:'Research'},{url:'https://example.com/other',title:'Other'}];
|
||||
test('restore creates owned native tabs once and selects saved active index',async()=>{const f=fixture();f.options.activeIndex=1;const r=await restoreNativeSession(tabs,f.options,f.deps);assert.deepEqual(r.sessionIds,['new1','new2']);assert.equal(r.activeSessionId,'new2');assert.equal(f.creates(),2);assert.deepEqual(f.removed,[]);assert.deepEqual(f.stages,['preflight','native','native']);});
|
||||
test('capacity and changed stored revision fail before any create',async()=>{for(const f of [fixture(32),fixture()]){if(f.records.length===1)f.setRevision('r2');await assert.rejects(restoreNativeSession(tabs,f.options,f.deps));assert.equal(f.creates(),0);assert.deepEqual(f.removed,[]);}});
|
||||
test('authority revoked during creation rolls back only created tabs without retry',async()=>{const f=fixture(),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.revoke();return id;};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/Authority revoked/);assert.equal(f.creates(),1);assert.deepEqual(f.removed,['new1']);assert.equal(f.active(),'old0');assert.deepEqual(f.records.map(r=>r.id),['old0']);assert.ok(f.stages.includes('rollback'));});
|
||||
test('internally removed failed creation is skipped during exact-owned rollback',async()=>{const f=fixture(),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.deps.registry.remove(id);throw Error('Native creation failed');};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/Native creation failed/);assert.deepEqual(f.removed,['new1']);assert.equal(f.creates(),1);assert.equal(f.active(),'old0');});
|
||||
test('external foreign tab change triggers failure without closing foreign tabs',async()=>{const f=fixture(),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.records.push({id:'foreign',contents:{isDestroyed:()=>false}});return id;};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps));assert.deepEqual(f.removed,['new1']);assert.deepEqual(f.records.map(r=>r.id),['old0','foreign']);});
|
||||
test('identity replacement prevents cleanup from removing replacement tab',async()=>{const f=fixture(),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.records.find(r=>r.id===id).contents={isDestroyed:()=>false};throw Error('Replaced');};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/Replaced/);assert.deepEqual(f.removed,[]);assert.equal(f.creates(),1);});
|
||||
test('storage revision changed during create rolls back created tabs',async()=>{const f=fixture(),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.setRevision('r2');return id;};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps));assert.deepEqual(f.removed,['new1']);assert.equal(f.creates(),1);});
|
||||
test('rollback attempts every exact-owned tab once when one close fails',async()=>{const f=fixture(),create=f.deps.create,remove=f.deps.registry.remove;f.deps.create=async(input,c)=>{const id=await create(input,c);if(f.creates()===2)f.revoke();return id;};const attempts=[];f.deps.registry.remove=id=>{attempts.push(id);if(id==='new2')throw Error('Close failed');remove(id);};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),e=>e.message==='Managed session rollback incomplete'&&e.cause.message==='Authority revoked');assert.deepEqual(attempts,['new2','new1']);assert.equal(f.active(),'old0');assert.equal(f.creates(),2);assert.deepEqual(f.records.map(r=>r.id),['old0','new2']);});
|
||||
test('create callback cannot claim original tab ownership for rollback',async()=>{const f=fixture();f.deps.create=async(_input,c)=>{c.onCreated(f.records[0]);return 'old0';};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/ownership mismatch/);assert.deepEqual(f.removed,[]);assert.deepEqual(f.records.map(r=>r.id),['old0']);});
|
||||
|
||||
test('switching away and back during creation invalidates activation epoch',async()=>{const f=fixture(2),create=f.deps.create;f.deps.create=async(input,c)=>{const id=await create(input,c);f.deps.activate('old1');f.deps.activate(id);return id;};await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/context changed/);assert.equal(f.creates(),1);assert.deepEqual(f.removed,['new1']);assert.equal(f.active(),'old0');});
|
||||
test('native activation hook rejects unexpected extra activation epochs',async()=>{const f=fixture(2),create=f.deps.create;f.deps.create=async(input,c)=>create(input,{...c,onActivated:native=>{f.deps.activate('old1');f.deps.activate(native.id);c.onActivated(native);}});await assert.rejects(restoreNativeSession(tabs,f.options,f.deps),/activation changed/);assert.deepEqual(f.removed,['new1']);});
|
||||
test('registry without native activation context is rejected before release',async()=>{const f=fixture();delete f.deps.registry.context;await assert.rejects(restoreNativeSession(tabs,f.options,f.deps));assert.equal(f.creates(),0);});
|
||||
@@ -0,0 +1,17 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';import os from 'node:os';import path from 'node:path';import crypto from 'node:crypto';
|
||||
import snapshot from '../apps/browser/session-snapshot.cjs';
|
||||
const {sanitizeSessionCollection,createSessionSnapshotStore}=snapshot;
|
||||
const sample=()=>({name:'Research',tabs:[{url:'https://example.test/docs',title:'Documentation',group:'Research'}],activeIndex:0});
|
||||
function withStore(fn){const dir=fs.mkdtempSync(path.join(os.tmpdir(),'safetynet-session-store-')),filePath=path.join(dir,'sessions.bin'),key=crypto.randomBytes(32);const options={filePath,protect:plain=>{const nonce=crypto.randomBytes(12),cipher=crypto.createCipheriv('aes-256-gcm',key,nonce),body=Buffer.concat([cipher.update(plain),cipher.final()]);return Buffer.concat([nonce,cipher.getAuthTag(),body]);},unprotect:bytes=>{const decipher=crypto.createDecipheriv('aes-256-gcm',key,bytes.subarray(0,12));decipher.setAuthTag(bytes.subarray(12,28));return Buffer.concat([decipher.update(bytes.subarray(28)),decipher.final()]);}};try{fn({store:createSessionSnapshotStore(options),options,filePath,dir});}finally{key.fill(0);fs.rmSync(dir,{recursive:true,force:true});}}
|
||||
const owner={deviceId:crypto.randomUUID(),ownerId:'agent:research'};
|
||||
test('session collection preserves usable native target but excludes serialized page state',()=>{assert.deepEqual(sanitizeSessionCollection(sample()),sample());for(const extra of ['pageState','formValues','history','password']){const value=sample();value.tabs[0][extra]='never persist';assert.throws(()=>sanitizeSessionCollection(value));}});
|
||||
test('session collection rejects auth targets and encoded known secrets rather than changing semantics',()=>{for(const url of ['https://example.test/?token=x','https://example.test/#code=x','https://example.test/callback','https://user:[email protected]/','file:///C:/secret','https://example.test/password%253Dsensitive','https://example.test/sk-123456789012345678901']){const value=sample();value.tabs[0].url=url;assert.throws(()=>sanitizeSessionCollection(value));}const value=sample();value.tabs[0].title='api_key=highlysensitive';assert.throws(()=>sanitizeSessionCollection(value));});
|
||||
test('session store ciphertext survives restart, enforces owner and CAS, preserves multiple collections',()=>withStore(({store,options,filePath})=>{let state=store.list(owner);const first=store.save(sample(),{...owner,expectedRevision:state.revision});assert.equal(fs.readFileSync(filePath).includes(Buffer.from('Documentation')),false);assert.throws(()=>store.save(sample(),{...owner,expectedRevision:state.revision}));state=store.list(owner);const second=store.save({...sample(),name:'Another'},{...owner,expectedRevision:state.revision});assert.equal(store.list(owner).collections.length,2);const restarted=createSessionSnapshotStore(options);assert.equal(restarted.load({...owner,id:first.id}).tabs[0].url,'https://example.test/docs');assert.equal(restarted.list({...owner,ownerId:'other'}).collections.length,0);assert.throws(()=>restarted.load({...owner,ownerId:'other',id:first.id}));assert.throws(()=>restarted.save({...sample(),id:first.id},{...owner,ownerId:'other',expectedRevision:second.revision}));restarted.remove({...owner,id:first.id,expectedRevision:second.revision});assert.equal(restarted.list(owner).collections.length,1);}));
|
||||
test('session store rejects ciphertext alteration and unsafe files',()=>withStore(({store,filePath,dir})=>{const initial=store.list(owner);store.save(sample(),{...owner,expectedRevision:initial.revision});const bytes=fs.readFileSync(filePath);bytes[bytes.length-1]^=1;fs.writeFileSync(filePath,bytes);assert.throws(()=>store.list(owner));fs.unlinkSync(filePath);fs.mkdirSync(filePath);assert.throws(()=>store.list(owner));fs.rmdirSync(filePath);fs.writeFileSync(filePath,'synthetic');fs.linkSync(filePath,path.join(dir,'alias'));assert.throws(()=>store.list(owner));}));
|
||||
test('session bounds reject oversized and structurally invalid collections',()=>{for(const change of [s=>s.tabs=Array.from({length:33},()=>s.tabs[0]),s=>s.activeIndex=1,s=>s.name='x'.repeat(101),s=>s.tabs[0].title='x'.repeat(201),s=>s.tabs[0].group='x'.repeat(101)]){const value=sample();change(value);assert.throws(()=>sanitizeSessionCollection(value));}});
|
||||
|
||||
test('benign query and fragment destinations retain exact navigation semantics',()=>{const value=sample();value.tabs[0].url='https://example.test/search?q=research&page=2#results';assert.equal(sanitizeSessionCollection(value).tabs[0].url,value.tabs[0].url);});
|
||||
test('persistence checks authority after protection before committing ciphertext',()=>withStore(({store,options,filePath})=>{const initial=store.list(owner);store.save(sample(),{...owner,expectedRevision:initial.revision});const before=fs.readFileSync(filePath);let permitted=true;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);permitted=false;return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Forbidden after change'},{...owner,expectedRevision:store.list(owner).revision,assertCurrent:()=>{if(!permitted)throw Error('Authority changed');}}));assert.deepEqual(fs.readFileSync(filePath),before);assert.equal(store.list(owner).collections.length,1);}));
|
||||
test('persistence rechecks CAS after a protection callback commits a competing collection',()=>withStore(({store,options,filePath})=>{const first=store.save(sample(),{...owner,expectedRevision:store.list(owner).revision});let competed=false;const racing=createSessionSnapshotStore({...options,protect:plain=>{const encrypted=options.protect(plain);if(!competed){competed=true;store.save({...sample(),name:'Winning update'},{...owner,expectedRevision:first.revision});}return encrypted;}});assert.throws(()=>racing.save({...sample(),name:'Stale overwrite'},{...owner,expectedRevision:first.revision}));assert.equal(store.list(owner).collections.some(row=>row.name==='Winning update'),true);assert.equal(store.list(owner).collections.some(row=>row.name==='Stale overwrite'),false);}));
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createServer} from 'node:http';
|
||||
import {DiagnosticsSchema} from '../packages/core/diagnostics.mjs';
|
||||
const require=createRequire(import.meta.url),{createDiagnostics}=require('../apps/browser/diagnostics.cjs'),{createAgentSessionAdapters}=require('../apps/browser/agent-session-adapters.cjs');
|
||||
async function fixture(t){const payloads=[],server=createServer((req,res)=>{const chunks=[];req.on('data',chunk=>chunks.push(chunk));req.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));const diagnostics=createDiagnostics();diagnostics.configure(DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000}));t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});let revision='r1',collection,restores=0;const policy={version:1,agents:{enabled:true,scopes:['sessions:save','sessions:list','sessions:restore'],allowedHosts:['example.com']}},c={ready:true,policy,sessionId:'00000000-0000-4000-8000-000000000001',token:'native-token',actor:{agentId:'private-actor',generation:1},webContents:{getURL:()=> 'https://example.com/private-project'},destinationAllowed:()=>true};const store={list:()=>({revision,collections:collection?[collection]:[]}),save:value=>{collection={id:'fixture-id',...value};revision='r2';return {id:collection.id,revision};},load:()=>({revision,...collection})};const adapters=createAgentSessionAdapters({getContext:()=>c,getSessions:()=>[{id:c.sessionId,title:'Internal roadmap fixture'}],contextCurrent:()=>true,getOwner:()=>({deviceId:'device-owner',ownerId:'private-owner'}),store,inspect:async()=>({decision:'allow',coverage:{complete:true}}),restore:async(_tabs,opts)=>{opts.assertCurrent();restores++;return {completed:true};},traceOperation:(operation,fn)=>diagnostics.span('browser.session',{operation:'sessions.'+operation,url:'https://example.com/private-project',title:'Internal roadmap fixture',token:'private-transport-token'},fn)});const run=(action,args)=>diagnostics.span('browser.agent',{operation:action},()=>adapters[action](args));return {diagnostics,payloads,run,restores:()=>restores,spans:()=>payloads.flatMap(p=>p.resourceSpans||[]).flatMap(p=>p.scopeSpans||[]).flatMap(p=>p.spans||[])};}
|
||||
test('session save/list/restore exports real OTLP stages correlated to each agent request without content',async t=>{const f=await fixture(t);await f.run('sessions:save',{name:'Roadmap collection fixture',sessionIds:['00000000-0000-4000-8000-000000000001']});await f.run('sessions:list',{});const plan=await f.run('sessions:restore',{id:'fixture-id'});await f.run('sessions:restore',{planId:plan.planId});await f.diagnostics.flush();const spans=f.spans(),op=s=>s.attributes.find(a=>a.key==='safetynet.operation')?.value.stringValue;assert.equal(f.restores(),1);for(const stage of ['sessions.inspect','sessions.save','sessions.list','sessions.load','sessions.restore']){const children=spans.filter(s=>s.name==='browser.session'&&op(s)===stage);assert.ok(children.length,stage);for(const child of children){const parent=spans.find(s=>s.spanId===child.parentSpanId);assert.ok(parent,stage+' parent');assert.equal(parent.name,'browser.agent');assert.equal(child.traceId,parent.traceId);assert.ok(BigInt(child.endTimeUnixNano)>=BigInt(child.startTimeUnixNano));}}for(const action of ['sessions:save','sessions:list','sessions:restore'])assert.ok(spans.some(s=>s.name==='browser.agent'&&op(s)===action));const serialized=JSON.stringify(f.payloads);for(const privateValue of ['private-project','Internal roadmap fixture','Roadmap collection fixture','private-transport-token','private-owner','private-actor','fixture-id',plan.planId])assert.equal(serialized.includes(privateValue),false,privateValue);assert.equal(f.diagnostics.status().failedExports,0);assert.equal(f.diagnostics.status().lifetimeFailedExports,0);});
|
||||
test('admin diagnostics disable stops export but never repeats or suppresses session operations',async t=>{const f=await fixture(t);await f.run('sessions:save',{name:'Fixture',sessionIds:['00000000-0000-4000-8000-000000000001']});await f.diagnostics.flush();const before=f.spans().length;assert.ok(before>0);f.diagnostics.configure({enabled:false});assert.equal(f.diagnostics.status().enabled,false);const p=await f.run('sessions:restore',{id:'fixture-id'});await f.run('sessions:restore',{planId:p.planId});await f.diagnostics.flush();assert.equal(f.restores(),1);assert.equal(f.spans().length,before);});
|
||||
test('synchronous audit stage exports retain exact returns, throws and parentage',async t=>{const f=await fixture(t),value={durable:true},error=new RangeError('private persistence failure');let calls=0;await f.diagnostics.span('browser.agent',{operation:'agent.audit'},()=>{assert.equal(f.diagnostics.spanSync('browser.management',{operation:'audit.serialize'},()=>{calls++;return value;}),value);assert.throws(()=>f.diagnostics.spanSync('browser.management',{operation:'audit.encrypt'},()=>{calls++;throw error;}),e=>e===error);for(const operation of ['audit.write','audit.fsync','audit.commit'])f.diagnostics.spanSync('browser.management',{operation},()=>{calls++;return 0;});});await f.diagnostics.flush();assert.equal(calls,5);const spans=f.spans(),parent=spans.find(s=>s.name==='browser.agent');for(const operation of ['audit.serialize','audit.encrypt','audit.write','audit.fsync','audit.commit']){const child=spans.find(s=>s.attributes.some(a=>a.key==='safetynet.operation'&&a.value.stringValue===operation));assert.ok(child,operation);assert.equal(child.parentSpanId,parent.spanId);assert.equal(child.traceId,parent.traceId);}assert.equal(spans.find(s=>s.attributes.some(a=>a.value.stringValue==='audit.encrypt')).status.code,2);assert.equal(JSON.stringify(f.payloads).includes('private persistence failure'),false);assert.equal(f.diagnostics.status().activeSpans,0);f.diagnostics.configure({enabled:false});const before=f.spans().length;assert.equal(f.diagnostics.spanSync('browser.management',{operation:'audit.write'},()=>{calls++;return value;}),value);assert.throws(()=>f.diagnostics.spanSync('browser.management',{operation:'audit.encrypt'},()=>{calls++;throw error;}),e=>e===error);await f.diagnostics.flush();assert.equal(calls,7);assert.equal(f.spans().length,before);});
|
||||
test('synchronous diagnostic instrumentation failure never replays protected work',async t=>{const f=await fixture(t);let calls=0;const hostile=new Proxy({}, {ownKeys(){throw Error('private attribute failure');}});assert.equal(f.diagnostics.spanSync('browser.security',hostile,()=>++calls),1);assert.equal(calls,1);assert.equal(f.diagnostics.spanSync('browser.security',{operation:'audit.commit'},()=>{calls++;f.diagnostics.configure({enabled:false});return 'committed';}),'committed');assert.equal(calls,2);await f.diagnostics.flush();assert.equal(f.diagnostics.status().activeSpans,0);assert.equal(f.spans().length,0);});
|
||||
Reference in new issue
Block a user