diff --git a/AGENT-API.md b/AGENT-API.md index 111222a..5ca978e 100644 --- a/AGENT-API.md +++ b/AGENT-API.md @@ -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}` | diff --git a/README.md b/README.md index 4613ebf..7bc0a85 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/browser/agent-control.cjs b/apps/browser/agent-control.cjs index 2851ee8..e729c5a 100644 --- a/apps/browser/agent-control.cjs +++ b/apps/browser/agent-control.cjs @@ -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'); diff --git a/apps/browser/agent-session-adapters.cjs b/apps/browser/agent-session-adapters.cjs new file mode 100644 index 0000000..69a521f --- /dev/null +++ b/apps/browser/agent-session-adapters.cjs @@ -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}; diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs index 090c00d..5dbe484 100644 --- a/apps/browser/diagnostics.cjs +++ b/apps/browser/diagnostics.cjs @@ -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}; diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs index 7b76442..e15abbf 100644 --- a/apps/browser/main.cjs +++ b/apps/browser/main.cjs @@ -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)}); diff --git a/apps/browser/session-restore.cjs b/apps/browser/session-restore.cjs new file mode 100644 index 0000000..91d9011 --- /dev/null +++ b/apps/browser/session-restore.cjs @@ -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}; diff --git a/apps/browser/session-snapshot.cjs b/apps/browser/session-snapshot.cjs new file mode 100644 index 0000000..2e27cd1 --- /dev/null +++ b/apps/browser/session-snapshot.cjs @@ -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}; diff --git a/apps/server/public/admin.js b/apps/server/public/admin.js index 7ea6c55..27bbb4e 100644 --- a/apps/server/public/admin.js +++ b/apps/server/public/admin.js @@ -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':'';} diff --git a/apps/server/public/agent-grants-admin.js b/apps/server/public/agent-grants-admin.js index 6250d74..602545d 100644 --- a/apps/server/public/agent-grants-admin.js +++ b/apps/server/public/agent-grants-admin.js @@ -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);} diff --git a/apps/server/public/index.html b/apps/server/public/index.html index 3e10b9e..20b5dc6 100644 --- a/apps/server/public/index.html +++ b/apps/server/public/index.html @@ -12,7 +12,7 @@

IDENTITY AND FEATURE ACCESS

User classes and website access

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.

ProfileAssignmentsDeveloper toolsAction

Create profile

Explicit device assignments
Denied domain categories

Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.

Preview effective device policy

Central categories and developer delegation

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.

Website categories

Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.

Assignment visibility

DeviceVerified subjectEffective profiles

CONTROLLED DEPLOYMENT

Groups and pilot rollouts

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.

GroupMembersStageActions

Create a device group

Group members
Rollout cohort

Pilot and expanded devices must belong to the group. All means all group members, not the entire fleet.

Additional restrictions
Settings section restrictions

Membership, restrictions and cohort changes are signed policy changes. Mandatory independent approval applies to these changes too.

Additional company bookmarks

These managed bookmarks are added for the selected rollout cohort. They do not replace baseline company bookmarks.

TitleURLFolderAction

Effective policy preview

Reported rollout state

DeviceEffective groupsReported / target versionVersion acknowledgmentLast contact

Acknowledgment compares the device-reported version with its target. It is not security attestation or proof that every control is functioning. Last contact is shown separately.

INDEPENDENT CHANGE CONTROL

Policy approval queue

Loading approval requirements.

Prepared changes remain inactive until approved. A different administrator must review the exact payload and base version. Stale changes must be prepared again against the latest policy.

PreparedAuthorChangeBase versionStatusReview

CHANGE CONTROL

Policy history and recovery

Review persisted signed versions and compare changes. Restoring reapplies the selected security rules, permissions, bookmarks and network settings, group memberships and rollout cohorts. The original version stays preserved; restoration publishes a new version with a fresh expiry. Devices never receive a lower policy version.

VersionPublishedActorReview

Managed browser settings

Hide settings or deny changes centrally. These permissions are enforced in the native browser process as well as the interface. Hiding a section denies its user actions; publishing a new signed policy applies changes to connected devices at refresh.

SectionHide and deny accessLock user actions
Appearance
Bookmarks and tab groups
AI workspace
Password manager
Manual document scan
Local agent controls

Appearance values become mandatory when appearance is hidden, locked, or settings access is disabled. Otherwise users may choose local preferences. Vault locking remains available to keep credentials safe. Mandatory security inspection continues independently of settings visibility. Publish signed policy above to apply.

COMPANY LIBRARY

Managed bookmarks

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.

AI library organization

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.

TitleHTTPS / HTTP URL (no query or fragment)FolderActions

Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.

Device personal bookmarks

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.

TEACH YOUR BROWSER

Define what must stay inside

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.

Local semantic reference protection

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.

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.

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.

-

Enterprise security and local agents

Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.

Agent capabilities

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.

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.

Individual agent identities

Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.

Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.

Advanced source configuration: YARA, Sigma and visual references

Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.

Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.

Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.

+

Enterprise security and local agents

Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.

Agent capabilities

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.

Saving, listing and restoring sessions require separate capabilities. Saved sessions contain approved URLs and tab group metadata only: no passwords, form values or page state. Restoration rechecks current policy, destination permissions and DLP; it never restores revoked agent authority.

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.

Individual agent identities

Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.

Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.

Advanced source configuration: YARA, Sigma and visual references

Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.

Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.

Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.

Governed document teaching

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.

Local password vault

Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.

Publish signed policy to apply these controls.

Visual impersonation protection

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.

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.

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.

Managed clipboard and ClickFix protection

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.

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.

DEVICE FLEET

Enrollment and access

Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.

DevicePlatformIdentityPolicyLast seenAccess

ADMINISTRATOR ACCESS

Active management sessions

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.

Session inventory requires the administrator role.

IdentityRoleExpiresAction

SECURITY OPERATIONS

Incident triage

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.

CreatedSecurity eventStatusSeverityAssigneeReview

AUDIT LIFECYCLE

Database event retention

Loading deployment configuration.

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.

SECURITY MONITORING

Recent audit events

TimeEventDeviceMetadata

This view shows the latest 100 events. Prompts and document contents are excluded. Exports contain up to 1,000 events; cursor-based APIs and durable HTTPS delivery support continuous SIEM ingestion.

COMPANY EXPERIENCE

Company logo and start page

Roll out your company identity through signed policy. Name and accent above apply alongside this logo.

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.

Download inspection

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.

PROTECTED SECURITY EVIDENCE

Protected evidence collection

Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.

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.

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.

OBSERVABILITY

Browser OpenTelemetry

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.

HTTPS is required except for a numeric loopback collector. Queue and export limits remain enforced. Browser logs and traces use OTLP. Durable security events are separately delivered by the management service in ECS or CEF; its HTTPS destination, TLS certificates and credentials are configured by the server operator, not exposed in browser policy. Backend service traces use the server OpenTelemetry configuration. Endpoint credentials do not belong in policy URLs. Disable and publish to stop new collection.

Review policy changes

Review the changed policy paths before publication. Existing server validation and version checks remain authoritative.

diff --git a/docs/agent-session-controls.md b/docs/agent-session-controls.md new file mode 100644 index 0000000..5f510f4 --- /dev/null +++ b/docs/agent-session-controls.md @@ -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. diff --git a/docs/development-observability.md b/docs/development-observability.md index 4c769e5..7d8b00f 100644 --- a/docs/development-observability.md +++ b/docs/development-observability.md @@ -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. diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md index dd1a395..c27480a 100644 --- a/docs/requirements-audit.md +++ b/docs/requirements-audit.md @@ -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. diff --git a/packages/core/agent-audit.cjs b/packages/core/agent-audit.cjs index 0d4e0d2..e90e647 100644 --- a/packages/core/agent-audit.cjs +++ b/packages/core/agent-audit.cjs @@ -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'}); diff --git a/packages/core/security.mjs b/packages/core/security.mjs index 2ff6b96..9a94ed7 100644 --- a/packages/core/security.mjs +++ b/packages/core/security.mjs @@ -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(); diff --git a/scripts/agent-grants-history-admin-smoke.cjs b/scripts/agent-grants-history-admin-smoke.cjs index d736924..6e9056e 100644 --- a/scripts/agent-grants-history-admin-smoke.cjs +++ b/scripts/agent-grants-history-admin-smoke.cjs @@ -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'); diff --git a/scripts/agent-grants-history-admin-smoke.mjs b/scripts/agent-grants-history-admin-smoke.mjs index 2e193a7..60ed067 100644 --- a/scripts/agent-grants-history-admin-smoke.mjs +++ b/scripts/agent-grants-history-admin-smoke.mjs @@ -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});} diff --git a/scripts/session-agent-controls-smoke.cjs b/scripts/session-agent-controls-smoke.cjs new file mode 100644 index 0000000..d187919 --- /dev/null +++ b/scripts/session-agent-controls-smoke.cjs @@ -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);}}); diff --git a/scripts/session-agent-controls-smoke.mjs b/scripts/session-agent-controls-smoke.mjs new file mode 100644 index 0000000..c3f0445 --- /dev/null +++ b/scripts/session-agent-controls-smoke.mjs @@ -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('Public research fixture

Public research

');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); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index e7e6e1d..f682f40 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -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}`); diff --git a/tests/admin-controls-ui.test.mjs b/tests/admin-controls-ui.test.mjs index 20e3255..daed0c3 100644 --- a/tests/admin-controls-ui.test.mjs +++ b/tests/admin-controls-ui.test.mjs @@ -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(`]*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(`]*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(`]*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'));}); diff --git a/tests/agent-session-adapters.test.mjs b/tests/agent-session-adapters.test.mjs new file mode 100644 index 0000000..61b8d12 --- /dev/null +++ b/tests/agent-session-adapters.test.mjs @@ -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:pass@example.com/','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);}}); diff --git a/tests/agent-session-transport.test.mjs b/tests/agent-session-transport.test.mjs new file mode 100644 index 0000000..ce72348 --- /dev/null +++ b/tests/agent-session-transport.test.mjs @@ -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:pass@example.test/'},{...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);}); diff --git a/tests/session-restore.test.mjs b/tests/session-restore.test.mjs new file mode 100644 index 0000000..f098e1b --- /dev/null +++ b/tests/session-restore.test.mjs @@ -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);}); diff --git a/tests/session-snapshot.test.mjs b/tests/session-snapshot.test.mjs new file mode 100644 index 0000000..b170bf2 --- /dev/null +++ b/tests/session-snapshot.test.mjs @@ -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:pass@example.test/','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);})); diff --git a/tests/session-stage-diagnostics.test.mjs b/tests/session-stage-diagnostics.test.mjs new file mode 100644 index 0000000..22ca5ca --- /dev/null +++ b/tests/session-stage-diagnostics.test.mjs @@ -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);});