Add scoped native history and window agent controls
This commit is contained in:
1 parent
adf0940e5f
commit
61fcd8336f
27 files changed
+503
-25
No files matched your search
@@ -34,7 +34,7 @@ S Browser uses translucent materials, rounded controls, light/dark themes, and a
|
||||
| Transfers | Bounded pre-release download scanning, byte-derived file-type checks, SHA-256 binding, configurable YARA and company ICAP. Supported clipboard, in-memory request bodies and outgoing HTTP headers receive policy checks. Ordinary Windows NTFS file uploads use held native handles and pre-release inspection; unsupported ranges, data pipes and streams remain blocked. |
|
||||
| Integrations | OIDC SSO; administrator/operator/auditor roles; system/fixed/PAC proxies; managed DNS; optional Windows VPN presence checks; ICAP/ICAPS inspection. |
|
||||
| Administration | Policy templates, diffs and history, independent publication review, groups, profiles, teaching workflows, incidents and configurable event retention. |
|
||||
| AI and agents | Approved AI routing with server-held credentials; scoped local agent navigation, tabs, page interaction, settings and bookmarks. Separate centrally approved agent identities, scoped credentials, expiry, generation rotation and targeted revocation; administrators can disable all agent access. |
|
||||
| AI and agents | Approved AI routing with server-held credentials; scoped local agent navigation, tabs, page interaction, settings, bookmarks, inspected history and bounded browser window controls. Separate centrally approved agent identities, scoped credentials, expiry, generation rotation and targeted revocation; administrators can disable all agent access. |
|
||||
| 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; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. |
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
function domAction(mode, selector, text, expectedUrl) {
|
||||
if (location.href !== expectedUrl) throw Error('Document changed before action');
|
||||
const visible = e => {
|
||||
@@ -106,15 +107,15 @@ const hostMatches = (hostname, pattern) => {
|
||||
const name=hostname.toLowerCase().replace(/\.$/,''),p=String(pattern).toLowerCase();
|
||||
return p.startsWith('*.') ? name.endsWith(p.slice(1)) && name!==p.slice(2) : name===p;
|
||||
};
|
||||
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,ai,inspect,audit,onInteraction,appControls={}} = {}) {
|
||||
function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,history,getHistory,goHistory,ai,inspect,audit,onInteraction,appControls={}} = {}) {
|
||||
if ([getContext,getSessions,contextCurrent,navigate,ai,inspect,audit].some(fn=>typeof fn!=='function')) throw TypeError('All adapter callbacks are required');
|
||||
const snapshot = (args,signal,action) => {
|
||||
const c = getContext(args.sessionId);
|
||||
if (signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.webContents || c.webContents.isDestroyed?.() || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
|
||||
if (!c.sessionId || !c.token || (args.sessionId !== undefined && args.sessionId !== c.sessionId)) throw Error('Unknown managed session');
|
||||
if (['click','type','scroll','select','check','back','forward','reload'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable');
|
||||
if (['click','type','scroll','select','check','back','forward','reload','history:go'].includes(action) && (c.foreground !== true || c.interactive===false)) throw Error('Managed session unavailable');
|
||||
const url = c.webContents.getURL();
|
||||
const changesDestination=['navigate','back','forward'].includes(action);
|
||||
const changesDestination=['navigate','back','forward','history:go'].includes(action);
|
||||
if (typeof c.destinationAllowed!=='function' || (!changesDestination && (!/^https?:\/\//.test(url) || c.destinationAllowed(url,c.policy)!==true))) throw Error('Session destination blocked');
|
||||
const agents=c.policy.agents;
|
||||
if (!Array.isArray(agents.scopes) || !agents.scopes.includes(action)) throw Error('Agent scope denied');
|
||||
@@ -152,7 +153,7 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
|
||||
await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.url,outcome:'completed',sensitiveField:false,...(target?{target}:{})});check(initial,args,signal);}
|
||||
return {completed:result?.completed===true};
|
||||
};
|
||||
const appActions=['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 appActions=['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'];
|
||||
// Native history owns target resolution and pre-release destination checks. A
|
||||
// refreshed token permits intentional navigation but never a different tab.
|
||||
const historyAdapters=Object.fromEntries(['back','forward','reload'].map(action=>[action,async(args={}, {signal}={})=>{
|
||||
@@ -166,6 +167,55 @@ await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.
|
||||
check({...refreshed,action,version:initial.version,url},args,signal,url);
|
||||
return {completed:true};
|
||||
}]));
|
||||
const historySnapshots=new Map();
|
||||
const historyInventory=initial=>{
|
||||
if(typeof getHistory!=='function')throw Error('Managed history unavailable');
|
||||
const data=getHistory(initial);
|
||||
if(!data||!Array.isArray(data.entries)||data.entries.length>100||!Number.isInteger(data.activeIndex)||data.activeIndex<0||data.activeIndex>=data.entries.length)throw Error('Invalid managed history');
|
||||
return {activeIndex:data.activeIndex,entries:data.entries.map(row=>{
|
||||
if(typeof row.url!=='string'||row.url.length>4096||typeof row.title!=='string'||row.title.length>200)throw Error('Invalid managed history');
|
||||
return {url:row.url,title:row.title};
|
||||
}),...(data.nativeBinding===undefined?{}:/^[a-f0-9]{64}$/.test(data.nativeBinding)&&typeof data.nativeBinding==='string'?{nativeBinding:data.nativeBinding}:(()=>{throw Error('Invalid managed history');})())};
|
||||
};
|
||||
const historyCredential=text=>{let value=text;for(let i=0;i<3;i++){if(secret(value))return true;try{const next=decodeURIComponent(value);if(next===value)break;value=next;}catch{break;}}return secret(value);};
|
||||
const historyAdaptersExtra={
|
||||
'history:list':async(args={}, {signal}={})=>{
|
||||
const initial=snapshot(args,signal,'history:list'),data=historyInventory(initial),binding=JSON.stringify(data),actorBinding=JSON.stringify(initial.actor??null);
|
||||
const current=()=>{check(initial,args,signal);if(getContext(initial.sessionId)?.policy!==initial.policy||JSON.stringify(getContext(initial.sessionId)?.actor??null)!==actorBinding||JSON.stringify(historyInventory(initial))!==binding)throw Error('Managed history changed during action');};
|
||||
const entries=[];
|
||||
for(let index=0;index<data.entries.length;index++){
|
||||
const row=data.entries[index];let url;try{url=new URL(row.url);}catch{continue;}
|
||||
if(!['http:','https:'].includes(url.protocol)||initial.destinationAllowed(url.href,initial.policy)!==true||(initial.policy.agents.allowedHosts.length&&!initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))continue;
|
||||
let decodedUrl=row.url;for(let pass=0;pass<3;pass++){try{const next=decodeURIComponent(decodedUrl);if(next===decodedUrl)break;decodedUrl=next;}catch{break;}}
|
||||
const credential=!!(url.username||url.password)||historyCredential(row.title+'\n'+row.url)||/[?&#](?:password|passwd|token|access[_-]?token|refresh[_-]?token|api[_-]?key|secret|client[_-]?secret|code|authorization|session|sid)=/i.test(decodedUrl);
|
||||
let allowed=false;
|
||||
if(!credential){
|
||||
const verdict=await inspect(row.title+'\n'+row.url,{...initial,action:'history:list'});current();
|
||||
if(!verdict||!['allow','block'].includes(verdict.decision)||verdict.coverage?.complete===false||/uninspectable|size-limit|inspection-incomplete|unavailable|failure|failed|timeout|unsupported|encrypted|parser/i.test(verdict.ruleId||''))throw Error('Inspection unavailable');
|
||||
allowed=verdict.decision==='allow';
|
||||
}
|
||||
entries.push({navigable:!credential,entryId:randomUUID(),title:allowed?row.title.slice(0,160):'Protected page',...(allowed?{url:row.url}:{}),active:index===data.activeIndex,index});
|
||||
}
|
||||
current();
|
||||
for(const [id,item] of historySnapshots)if(item.expires<=Date.now())historySnapshots.delete(id);
|
||||
while(historySnapshots.size>=32)historySnapshots.delete(historySnapshots.keys().next().value);
|
||||
const snapshotId=randomUUID();historySnapshots.set(snapshotId,{initial,binding,actorBinding,entries,expires:Date.now()+60000});
|
||||
return {snapshotId,entries:entries.map(({index,navigable,...row})=>row)};
|
||||
},
|
||||
'history:go':async(args={}, {signal}={})=>{
|
||||
const initial=snapshot(args,signal,'history:go'),saved=historySnapshots.get(args.snapshotId);
|
||||
if(!saved||saved.expires<=Date.now()||saved.initial.sessionId!==initial.sessionId||saved.actorBinding!==JSON.stringify(initial.actor??null)||saved.initial.webContents!==initial.webContents||saved.initial.policy!==initial.policy||saved.initial.version!==initial.version||!contextCurrent(saved.initial.token)||saved.initial.url!==initial.url||JSON.stringify(historyInventory(initial))!==saved.binding)throw Error('Managed history changed during action');
|
||||
const entry=saved.entries.find(row=>row.entryId===args.entryId);
|
||||
if(!entry||entry.navigable!==true||typeof goHistory!=='function')throw Error('Unknown managed history entry');
|
||||
const target=JSON.parse(saved.binding).entries[entry.index].url,url=new URL(target);
|
||||
if(!['http:','https:'].includes(url.protocol)||url.username||url.password||initial.destinationAllowed(target,initial.policy)!==true||(initial.policy.agents.allowedHosts.length&&!initial.policy.agents.allowedHosts.some(p=>hostMatches(url.hostname,p))))throw Error('Destination blocked');
|
||||
check(initial,args,signal);historySnapshots.delete(args.snapshotId);
|
||||
const result=await goHistory(entry.index,initial,{signal,historyBinding:saved.binding}),refreshed=result?.context;
|
||||
if(result?.completed!==true||!refreshed||refreshed.sessionId!==initial.sessionId||refreshed.webContents!==initial.webContents||refreshed.policy!==initial.policy||(refreshed.policyVersion??refreshed.policy?.version)!==initial.version)throw Error('Managed session changed during action');
|
||||
check({...refreshed,action:'history:go',version:initial.version,url:target},args,signal,target);
|
||||
return {completed:true};
|
||||
}
|
||||
};
|
||||
const appSnapshot=action=>{const c=getContext();if(!c?.ready||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||(c.policy.expiresAt&&Date.parse(c.policy.expiresAt)<=Date.now()))throw Error('Agent scope denied');return c;};
|
||||
const appAdapters=Object.fromEntries(appActions.map(action=>[action,async(args={}, {signal}={})=>{
|
||||
const initial=appSnapshot(action),version=initial.policyVersion??initial.policy.version;
|
||||
@@ -186,6 +236,7 @@ await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.
|
||||
return {
|
||||
...appAdapters,
|
||||
...historyAdapters,
|
||||
...historyAdaptersExtra,
|
||||
sessions:async(args={}, {signal}={})=>{
|
||||
const c=getContext();
|
||||
if(signal?.aborted || !c?.ready || !c.policy?.agents?.enabled || !c.policy.agents.scopes?.includes('sessions') || (c.policy.expiresAt && Date.parse(c.policy.expiresAt)<=Date.now())) throw Error('Managed session unavailable');
|
||||
|
||||
@@ -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(['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(['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,14 @@ 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 = { 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 = { '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.startsWith('history:')){
|
||||
clean.sessionId=bounded(args.sessionId,128,'sessionId');
|
||||
if(action==='history:go')for(const key of ['snapshotId','entryId']){clean[key]=bounded(args[key],36,key);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[key]))throw fail(400,'Invalid history handle');}
|
||||
}
|
||||
if(action==='window:control'){if(!['minimize','maximize','restore','focus'].includes(args.command))throw fail(400,'Invalid window command');clean.command=args.command;}
|
||||
if (action !== 'sessions' && args.sessionId !== undefined) clean.sessionId = bounded(args.sessionId, 128, 'sessionId');
|
||||
if (action === 'navigate') {
|
||||
let url; try { url = new URL(bounded(args.url, 4096, 'url')); } catch { throw fail(400, 'Invalid URL'); }
|
||||
@@ -50,6 +55,21 @@ function validate(body) {
|
||||
}
|
||||
// Never return adapter objects wholesale: session handles and sanitized text only.
|
||||
function project(action, result) {
|
||||
if(action==='history:list'){
|
||||
const uuid=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 history response');return value;};
|
||||
if(!Array.isArray(result?.entries)||result.entries.length>100)throw fail(502,'Invalid history response');
|
||||
return {snapshotId:uuid(result.snapshotId),entries:result.entries.map(row=>{
|
||||
if(typeof row.title!=='string'||row.title.length>200||typeof row.active!=='boolean')throw fail(502,'Invalid history response');
|
||||
const item={entryId:uuid(row.entryId),title:row.title,active:row.active};
|
||||
if(row.url!==undefined){let u;try{u=new URL(row.url);}catch{throw fail(502,'Invalid history response');}if(typeof row.url!=='string'||row.url.length>4096||!['http:','https:'].includes(u.protocol)||u.username||u.password)throw fail(502,'Invalid history response');item.url=u.href;}
|
||||
return item;
|
||||
})};
|
||||
}
|
||||
if(action==='window:state'){
|
||||
if(!result||!['width','height'].every(key=>Number.isSafeInteger(result[key])&&result[key]>=1&&result[key]<=32768)||!['visible','focused','minimized','maximized','fullScreen'].every(key=>typeof result[key]==='boolean'))throw fail(502,'Invalid window response');
|
||||
return Object.fromEntries(['width','height','visible','focused','minimized','maximized','fullScreen'].map(key=>[key,result[key]]));
|
||||
}
|
||||
if(action==='history:go'||action==='window:control')return {completed:result?.completed===true};
|
||||
if(action==='application:state')return {ready:result?.ready===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0,activeTabId:typeof result?.activeTabId==='string'?result.activeTabId.slice(0,128):null,tabCount:Number.isSafeInteger(result?.tabCount)?result.tabCount:0};
|
||||
if(action==='settings:get'||action==='settings:update'){const appearance=result?.appearance||{};return {visible:result?.visible===true,hiddenSections:Array.isArray(result?.hiddenSections)?result.hiddenSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],lockedSections:Array.isArray(result?.lockedSections)?result.lockedSections.filter(s=>['appearance','library','ai','passwords','documentScan','agents'].includes(s)):[],appearance:{mode:['system','light','dark'].includes(appearance.mode)?appearance.mode:'system',accent:['blue','teal','violet'].includes(appearance.accent)?appearance.accent:'blue',tabMode:['compact','separate'].includes(appearance.tabMode)?appearance.tabMode:'separate'}};}
|
||||
if(action==='bookmarks:list'){const items=values=>{if(!Array.isArray(values)||values.length>1000)throw fail(502,'Invalid bookmarks');return values.map(item=>({id:bounded(item.id,80,'bookmark id'),title:bounded(item.title,120,'bookmark title'),url:bounded(item.url,2048,'bookmark URL'),folder:typeof item.folder==='string'?item.folder.slice(0,80):''}));};return {managed:items(result?.managed||[]),personal:{revision:Number.isSafeInteger(result?.personal?.revision)?result.personal.revision:0,items:items(result?.personal?.items||[])},allowUserAdd:result?.allowUserAdd===true,allowUserEdit:result?.allowUserEdit===true,allowUserDelete:result?.allowUserDelete===true,policyVersion:Number.isSafeInteger(result?.policyVersion)?result.policyVersion:0};}
|
||||
|
||||
@@ -10,9 +10,10 @@ const {resourceFromAttributes}=require('@opentelemetry/resources');
|
||||
const {OTLPTraceExporter}=require('@opentelemetry/exporter-trace-otlp-http');
|
||||
const {OTLPLogExporter}=require('@opentelemetry/exporter-logs-otlp-http');
|
||||
const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=require('@opentelemetry/api');
|
||||
const names=new Set(['browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']);
|
||||
const names=new Set(['browser.window','browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']);
|
||||
const operations=new Set(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']);
|
||||
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
|
||||
for(const operation of ['history:list','history:go','window:state','window:control'])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);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
'use strict';
|
||||
// Native history only: pages and agents cannot supply a navigation target.
|
||||
async function performHistory({contents,action,allowed,current,signal,timeoutMs=10000}={}) {
|
||||
if(!contents||!['back','forward','reload'].includes(action)||typeof allowed!=='function'||typeof current!=='function'||!Number.isFinite(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw Error('Invalid history operation');
|
||||
async function performHistory({contents,action,allowed,current,signal,index,getSnapshot,historyBinding,timeoutMs=10000}={}) {
|
||||
if(!contents||!['back','forward','reload','index'].includes(action)||typeof allowed!=='function'||typeof current!=='function'||!Number.isFinite(timeoutMs)||timeoutMs<1||timeoutMs>60000||action==='index'&&(!Number.isSafeInteger(index)||index<0||typeof getSnapshot!=='function'||typeof historyBinding!=='string'))throw Error('Invalid history operation');
|
||||
const guard=async(phase='before')=>{if(signal?.aborted)throw Error('History operation cancelled');if(contents.isDestroyed?.())throw Error('History contents unavailable');if(await current(phase)!==true)throw Error('History context changed');if(signal?.aborted)throw Error('History operation cancelled');};
|
||||
const target=()=>action==='reload'?contents.getURL():contents.navigationHistory.getEntryAtIndex(contents.navigationHistory.getActiveIndex()+(action==='back'?-1:1))?.url;
|
||||
const target=()=>action==='reload'?contents.getURL():contents.navigationHistory.getEntryAtIndex(action==='index'?index:contents.navigationHistory.getActiveIndex()+(action==='back'?-1:1))?.url;
|
||||
await guard();const url=target();
|
||||
if(!url)return {completed:false,url:contents.getURL()};
|
||||
let parsed;try{parsed=new URL(url);}catch{throw Error('Invalid history destination');}
|
||||
if(!['http:','https:'].includes(parsed.protocol)||parsed.username||parsed.password||!await allowed(url))throw Error('History destination denied');
|
||||
await guard();if(target()!==url)throw Error('History destination changed');
|
||||
if(action==='index'&&index===contents.navigationHistory.getActiveIndex()){
|
||||
if(JSON.stringify(getSnapshot())!==historyBinding||contents.getURL()!==url)throw Error('History snapshot changed');return {completed:true,url};
|
||||
}
|
||||
return new Promise((resolve,reject)=>{
|
||||
let settled=false,dispatched=false,checking=false;
|
||||
const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);for(const [name,fn] of listeners)contents.removeListener(name,fn);};
|
||||
@@ -21,7 +24,7 @@ async function performHistory({contents,action,allowed,current,signal,timeoutMs=
|
||||
const timer=setTimeout(()=>fail(Error('History operation timed out')),timeoutMs);
|
||||
for(const [name,fn] of listeners)contents.on(name,fn);signal?.addEventListener('abort',abort,{once:true});
|
||||
if(signal?.aborted){abort();return;}
|
||||
try{dispatched=true;if(action==='reload')contents.reload();else contents.navigationHistory.goToIndex(contents.navigationHistory.getActiveIndex()+(action==='back'?-1:1));}catch(error){fail(error);}
|
||||
try{if(action==='index'&&JSON.stringify(getSnapshot())!==historyBinding)throw Error('History snapshot changed');if(target()!==url)throw Error('History destination changed');dispatched=true;if(action==='reload')contents.reload();else contents.navigationHistory.goToIndex(action==='index'?index:contents.navigationHistory.getActiveIndex()+(action==='back'?-1:1));}catch(error){fail(error);}
|
||||
});
|
||||
}
|
||||
module.exports={performHistory};
|
||||
@@ -0,0 +1,18 @@
|
||||
'use strict';
|
||||
const {createHash}=require('node:crypto');
|
||||
// Chromium pageState can contain form values. Keep only a private comparison
|
||||
// checksum; never expose serialized pageState or the checksum through the API.
|
||||
function readHistorySnapshot(contents){
|
||||
if(!contents||contents.isDestroyed())throw Error('Managed history unavailable');
|
||||
const history=contents.navigationHistory,rows=history.getAllEntries(),activeIndex=history.getActiveIndex();
|
||||
if(!Array.isArray(rows)||rows.length<1||rows.length>100||!Number.isInteger(activeIndex)||activeIndex<0||activeIndex>=rows.length)throw Error('History size limit');
|
||||
const hash=createHash('sha256');let total=0;
|
||||
const entries=rows.map(row=>{
|
||||
if(typeof row.url!=='string'||row.url.length>4096||typeof row.title!=='string'||row.title.length>200||row.pageState!==undefined&&typeof row.pageState!=='string')throw Error('History size limit');
|
||||
const state=row.pageState??null;if(state!==null){total+=state.length;if(state.length>1048576||total>8388608)throw Error('History size limit');}
|
||||
const bytes=Buffer.from(JSON.stringify([row.url,row.title,state]));try{hash.update(bytes);}finally{bytes.fill(0);}
|
||||
return {url:row.url,title:row.title};
|
||||
});
|
||||
return {activeIndex,entries,nativeBinding:hash.digest('hex')};
|
||||
}
|
||||
module.exports={readHistorySnapshot};
|
||||
+19
-3
@@ -329,7 +329,19 @@ async function agentHistory(action,captured,{signal}={}){
|
||||
const result=await diagnosticSpan('browser.navigation',{operation:action},()=>require('./history-navigation.cjs').performHistory({contents,action,allowed,current,signal}));
|
||||
if(!current())throw Error('History context changed');emit();return {completed:result.completed,context:agentContext(tab.id)};
|
||||
}
|
||||
function agentContext(id){const tab=id?tabs.get(id):activeTab(),foreground=tab===activeTab();return {ready:ready&&featureAllowed(managedSettings(),'agents'),interactive:foreground&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,policyVersion:policy?.version,sessionId:tab.id,foreground,token:tabContext(tab.id,{foreground}),webContents:tab.view.webContents,destinationAllowed:url=>actorDestinationAllowed(url)};}
|
||||
function nativeHistorySnapshot(contents){
|
||||
return require('./history-snapshot.cjs').readHistorySnapshot(contents);
|
||||
}
|
||||
function getAgentHistory(captured){if(!currentTabContext(captured.token)||captured.webContents.isDestroyed())throw Error('Agent context changed');return nativeHistorySnapshot(captured.webContents);}
|
||||
async function goAgentHistory(index,captured,{signal,historyBinding}={}){
|
||||
if(!currentTabContext(captured.token)||captured.foreground!==true)throw Error('Agent context changed');
|
||||
const tab=tabs.get(captured.sessionId),contents=tab.contents,initial=policy,activation=captured.token.activation;
|
||||
const current=(phase='after')=>ready&&policy===initial&&!signal?.aborted&&!contents.isDestroyed()&&tabs.fromContents(contents.id)===tab&&activeTab()===tab&&tabContext(tab.id).activation===activation&&(phase!=='before'||currentTabContext(captured.token));
|
||||
const allowed=url=>actorDestinationAllowed(url)&&(!initial.agents.allowedHosts.length||initial.agents.allowedHosts.some(pattern=>core.hostMatches(new URL(url).hostname,pattern)));
|
||||
const result=await diagnosticSpan('browser.navigation',{operation:'history:go'},()=>require('./history-navigation.cjs').performHistory({contents,action:'index',index,getSnapshot:()=>nativeHistorySnapshot(contents),historyBinding,allowed,current,signal}));
|
||||
if(!current())throw Error('History context changed');emit();return {completed:result.completed,context:agentContext(tab.id)};
|
||||
}
|
||||
function agentContext(id){const tab=id?tabs.get(id):activeTab(),foreground=tab===activeTab(),actor=agentActorStorage.getStore();return {ready:ready&&featureAllowed(managedSettings(),'agents'),interactive:foreground&&!tab.startPage&&!overviewPanel&&!toolsOpen,policy,policyVersion:policy?.version,sessionId:tab.id,foreground,token:tabContext(tab.id,{foreground}),webContents:tab.view.webContents,...(actor?.principalId?{actor:Object.freeze({agentId:actor.principalId,generation:actor.generation})}:{}),destinationAllowed:url=>actorDestinationAllowed(url)};}
|
||||
function managedAI(...args){return diagnosticSpan('browser.ai',{},()=>managedAIImpl(...args));}
|
||||
async function managedAIImpl(prompt){
|
||||
requireFeatureSection('ai');
|
||||
@@ -387,7 +399,7 @@ async function ensureAgentAPI(){
|
||||
if(!ready||!policy.agents.enabled||!featureAllowed(managedSettings(),'agents')||agentService||agentStarting)return;agentStarting=true;
|
||||
try{
|
||||
const getContext=agentContext;
|
||||
const actions=createAgentAdapters({getContext,appControls:agentAppControls(),getSessions:()=>tabs.all().filter(tab=>!agentActorStorage.getStore()?.principalId||actorDestinationAllowed(tab.view.webContents.getURL())).map(tab=>({id:tab.id,title:tab.title,foreground:tab===activeTab()})),contextCurrent:currentTabContext,navigate,history:agentHistory,ai:managedAI,
|
||||
const actions=createAgentAdapters({getContext,appControls:agentAppControls(),getSessions:()=>tabs.all().filter(tab=>!agentActorStorage.getStore()?.principalId||actorDestinationAllowed(tab.view.webContents.getURL())).map(tab=>({id:tab.id,title:tab.title,foreground:tab===activeTab()})),contextCurrent:currentTabContext,navigate,history:agentHistory,getHistory:getAgentHistory,goHistory:goAgentHistory,ai:managedAI,
|
||||
inspect:async(text,context)=>{
|
||||
const aiContext=context.action==='ai';
|
||||
let verdict=aiContext?core.inspectForAI(Buffer.from(text),context.policy):core.inspect(Buffer.from(text),context.policy);if(verdict.decision==='block')return verdict;
|
||||
@@ -401,7 +413,7 @@ async function ensureAgentAPI(){
|
||||
}});
|
||||
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','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
|
||||
if(context.principalId&&['navigate','back','forward','reload','history:go','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
|
||||
return original(args,context);
|
||||
}));}
|
||||
agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[],
|
||||
@@ -627,11 +639,15 @@ function updateManagedAppearance(input){requireManagedSection('appearance');appe
|
||||
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();}
|
||||
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)});
|
||||
const checked=(operation)=>async(args,{signal,policyVersion}={})=>{
|
||||
if(signal?.aborted||!ready||policy?.version!==policyVersion||!featureAllowed(managedSettings(),'agents'))throw Error('Managed application changed');
|
||||
return operation(args,{signal});
|
||||
};
|
||||
return {
|
||||
'window:state':checked((args,options)=>windowControls.state(args,options)),
|
||||
'window:control':checked((args,options)=>windowControls.control(args,options)),
|
||||
'application:state':checked(()=>({ready,policyVersion:policy.version,activeTabId:activeTab()?.id,tabCount:tabs.all().length})),
|
||||
'settings:get':checked(()=>managedSettings()),
|
||||
'settings:update':checked(args=>updateManagedAppearance(args.appearance)),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use strict';
|
||||
// Only the managed application window is exposed. Screen positions, titles,
|
||||
// arbitrary native handles and executable arguments never cross this boundary.
|
||||
const commands = Object.freeze(['minimize','maximize','restore','focus']);
|
||||
function createWindowControls({getWindow,getContext,isCurrent,authorize=()=>true,traceOperation=(_name,fn)=>fn()}={}) {
|
||||
if([getWindow,getContext,isCurrent,authorize,traceOperation].some(fn=>typeof fn!=='function'))throw TypeError('Window callbacks are required');
|
||||
const snapshot=(action,signal)=>{
|
||||
const context=getContext(),window=getWindow(),policy=context?.policy;
|
||||
if(signal?.aborted||context?.ready!==true||policy?.agents?.enabled!==true||!Array.isArray(policy.agents.scopes)||!policy.agents.scopes.includes(action))throw Error('Agent window scope denied');
|
||||
if(policy.expiresAt&&(!Number.isFinite(Date.parse(policy.expiresAt))||Date.parse(policy.expiresAt)<=Date.now()))throw Error('Managed policy expired');
|
||||
if(!window||window.isDestroyed()||!window.webContents||window.webContents.isDestroyed())throw Error('Managed window unavailable');
|
||||
return {context,window,contents:window.webContents,policy,version:context.policyVersion??policy.version};
|
||||
};
|
||||
const check=(initial,action,signal)=>{
|
||||
const latest=snapshot(action,signal);
|
||||
if(latest.window!==initial.window||latest.contents!==initial.contents||latest.policy!==initial.policy||latest.version!==initial.version||isCurrent(initial.context)!==true)throw Error('Managed window changed during action');
|
||||
};
|
||||
const approved=async(initial,action,signal)=>{
|
||||
check(initial,action,signal);
|
||||
if(await authorize(action,initial.context)!==true)throw Error('Agent window authorization denied');
|
||||
check(initial,action,signal);
|
||||
};
|
||||
const project=window=>{
|
||||
const bounds=window.getBounds();
|
||||
if(!bounds||!['width','height'].every(key=>Number.isSafeInteger(bounds[key])&&bounds[key]>=0&&bounds[key]<=65535))throw Error('Invalid managed window dimensions');
|
||||
const result={width:bounds.width,height:bounds.height};
|
||||
for(const [key,method] of [['visible','isVisible'],['focused','isFocused'],['minimized','isMinimized'],['maximized','isMaximized'],['fullScreen','isFullScreen']]){
|
||||
const value=window[method]();if(typeof value!=='boolean')throw Error('Invalid managed window state');result[key]=value;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const run=async(action,args,options={})=>{
|
||||
if(!args||Object.getPrototypeOf(args)!==Object.prototype||Object.keys(args).some(key=>action==='window:control'?key!=='command':true)||action==='window:control'&&!commands.includes(args.command))throw Error('Invalid window action');
|
||||
const {signal}=options,initial=snapshot(action,signal);
|
||||
await approved(initial,action,signal);
|
||||
return traceOperation(action,async()=>{
|
||||
await approved(initial,action,signal);
|
||||
if(action==='window:control'){
|
||||
// Electron operations are synchronous. Do not retry an effect after a
|
||||
// subsequent cancellation, policy change or diagnostic delivery error.
|
||||
try{initial.window[args.command]();}catch{throw Error('Managed window operation failed');}
|
||||
await Promise.resolve();
|
||||
}
|
||||
await approved(initial,action,signal);
|
||||
const state=project(initial.window);check(initial,action,signal);
|
||||
return action==='window:control'?{completed:true,state}:state;
|
||||
});
|
||||
};
|
||||
return {state:(args={},options)=>run('window:state',args,options),control:(args,options)=>run('window:control',args,options)};
|
||||
}
|
||||
module.exports={createWindowControls};
|
||||
@@ -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'];
|
||||
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'];
|
||||
|
||||
function status(message,error=false){$('status').textContent=message;$('status').className=error?'error':'';}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ function renderAgentGrants(){
|
||||
const enabled=agentGrantField(grid,'Enabled');enabled.type='checkbox';enabled.checked=grant.enabled;enabled.disabled=!agentGrantsEditable;enabled.addEventListener('change',()=>{grant.enabled=enabled.checked;changedAgentGrants();});
|
||||
const expiry=agentGrantField(grid,'Expiry (UTC)');expiry.type='text';expiry.value=grant.expiresAt;expiry.placeholder='2026-12-31T23:59:59Z';expiry.disabled=!agentGrantsEditable;expiry.addEventListener('input',()=>{grant.expiresAt=expiry.value.trim();changedAgentGrants();});
|
||||
const hosts=agentGrantField(grid,'Additional destination allowlist','textarea');hosts.value=grant.allowedHosts.join('\n');hosts.placeholder='Empty adds no destination restriction';hosts.disabled=!agentGrantsEditable;hosts.addEventListener('input',()=>{grant.allowedHosts=hosts.value.split('\n').map(v=>v.trim()).filter(Boolean);changedAgentGrants();});
|
||||
const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const 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='History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox);
|
||||
for(const scope of agentScopes){const label=document.createElement('label');const check=document.createElement('input');check.type='checkbox';check.checked=grant.scopes.includes(scope);check.disabled=!agentGrantsEditable;label.append(check,document.createTextNode(scope));scopes.append(label);check.addEventListener('change',()=>{grant.scopes=agentScopes.filter(s=>s===scope?check.checked:grant.scopes.includes(s));changedAgentGrants();});}
|
||||
const actions=document.createElement('div');actions.className='row';const generation=document.createElement('span');generation.textContent=`Credential generation: ${grant.generation}`;const rotate=document.createElement('button');rotate.type='button';rotate.className='secondary';rotate.textContent='Invalidate previous credentials';rotate.disabled=!agentGrantsEditable||grant.generation>=Number.MAX_SAFE_INTEGER;rotate.addEventListener('click',()=>{grant.generation+=1;renderAgentGrants();changedAgentGrants();agentGrantStatus('Credential generation changed in draft. Publish after review, then pair this identity again locally.');});const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='Revoke grant';revoke.disabled=!agentGrantsEditable||!grant.enabled;revoke.addEventListener('click',()=>{grant.enabled=false;renderAgentGrants();changedAgentGrants();agentGrantStatus('Grant disabled in draft. Publish after review to apply revocation.');});actions.append(generation,rotate,revoke);
|
||||
if(!agentGrantsPublishedIds.has(grant.id)){const remove=document.createElement('button');remove.type='button';remove.className='secondary';remove.textContent='Remove unpublished grant';remove.disabled=!agentGrantsEditable;remove.addEventListener('click',()=>{agentGrantsDraft=agentGrantsDraft.filter(g=>g!==grant);renderAgentGrants();changedAgentGrants();});actions.append(remove);}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<section id="user-profiles" class="card"><p class="eyebrow">IDENTITY AND FEATURE ACCESS</p><h2>User classes and website access</h2><p>Create profiles for any user class with your own names and controls. Employee, developer and high-protection templates are optional starting points. Assign profiles to verified enterprise subjects or explicit managed devices. Asset-owner labels are not authenticated identities. Website denials and category restrictions accumulate across matching profiles; an allowlist does not bypass a denial.</p><button id="profiles-refresh" class="secondary">Refresh profiles</button><p id="profiles-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Profile</th><th>Assignments</th><th>Developer tools</th><th>Action</th></tr></thead><tbody id="profiles-rows"></tbody></table></div><div class="row"><button id="profile-new" class="secondary">Create your own profile</button><label>Starting class<select id="profile-template"><option value="employee">Employee</option><option value="developer">Developer</option><option value="high-protection">High protection</option></select></label><button id="profile-use-template" class="secondary">Use class in draft</button></div><h3 id="profile-editor-title">Create profile</h3><div class="grid"><label>Identifier<input id="profile-id" maxlength="80"></label><label>Name<input id="profile-name" maxlength="100"></label><label>Verified enterprise subject identifiers<textarea id="profile-subjects" placeholder="Exact issuer-verified subject; one per line"></textarea></label><fieldset><legend>Explicit device assignments</legend><div id="profile-devices" class="device-checklist"></div></fieldset><label>Allowed website hosts<textarea id="profile-allowed" placeholder="Empty adds no allowlist restriction"></textarea></label><label>Denied website hosts<textarea id="profile-blocked" placeholder="*.unapproved.example"></textarea></label><fieldset><legend>Denied domain categories</legend><div id="profile-categories" class="device-checklist"></div></fieldset><label>Developer tools / browser console<select id="profile-devtools"><option value="inherit">Inherit baseline</option><option value="deny">Deny</option><option value="allow">Allow within delegated baseline</option></select></label><label>Downloads<select id="profile-downloads"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>AI workspace<select id="profile-ai"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label>Local agents<select id="profile-agents"><option value="inherit">Inherit</option><option value="deny">Deny</option></select></label><label class="checks"><input id="profile-scan" type="checkbox"> Require document analysis</label></div><p class="muted">Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.</p><div class="row"><button id="profile-save">Publish profile</button><button id="profile-delete" class="danger" disabled>Delete profile</button></div><h3>Preview effective device policy</h3><div class="row"><select id="profile-preview-device" aria-label="Preview device"></select><button id="profile-preview" class="secondary">Preview current draft</button></div><p id="profile-preview-summary" aria-live="polite"></p><div id="profile-preview-diff"></div><details><summary>Central categories and developer delegation</summary><p>Categories contain your approved domain lists. This is not an automatic external website reputation feed. Enabling delegation permits reviewed developer-profile grants; denying profiles still win.</p><label class="checks"><input id="profile-delegation" type="checkbox"> Permit profiles to grant developer tools</label><h4>Website categories</h4><p>Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.</p><button id="profile-category-add" class="secondary">Add website category</button><button id="profile-category-reset" class="secondary">Discard category draft</button><p id="profile-category-status" role="status" aria-live="polite"></p><div id="profile-category-editor"></div><button id="profile-config-save">Publish categories and delegation</button></details><h3>Assignment visibility</h3><div class="table-wrap"><table><thead><tr><th>Device</th><th>Verified subject</th><th>Effective profiles</th></tr></thead><tbody id="profile-device-rows"></tbody></table></div></section><section class="card" id="policy-groups"><p class="eyebrow">CONTROLLED DEPLOYMENT</p><h2>Groups and pilot rollouts</h2><p>Apply additional restrictions to explicit device groups. Start with selected pilot devices, inspect their reported policy versions, then explicitly expand the cohort. Group rules can tighten the baseline; they cannot weaken it. No automatic promotion occurs.</p><button id="groups-refresh" classLine truncated
|
||||
<section class="card" id="bookmarks"><p class="eyebrow">COMPANY LIBRARY</p><h2>Managed bookmarks</h2><p>Publish intranet and approved service links to every browser. Company bookmarks are read-only for employees. Personal bookmarks remain separate; configure whether employees may add, edit or delete them.</p><div class="checks"><label><input type="checkbox" id="bookmarks-add"> Allow personal additions</label><label><input type="checkbox" id="bookmarks-edit"> Allow personal edits</label><label><input type="checkbox" id="bookmarks-delete"> Allow personal deletion</label><label><input type="checkbox" id="bookmarks-admin-read"> Allow administrators to read and edit personal bookmarks</label></div><fieldset><legend>AI library organization</legend><div class="checks"><label><input id="library-ai-enabled" type="checkbox"> Enable approved AI organization suggestions</label><label><input id="library-ai-autoapply" type="checkbox"> Permit agent application without an additional user review</label></div><p class="muted">Suggestions require the approved AI gateway and Library permissions. Agents also require the organization scopes below. Company bookmarks remain protected. Keep automatic application disabled to require explicit confirmation before changes.</p></fieldset><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="managed-bookmark-rows"></tbody></table></div><button class="secondary" id="managed-bookmark-add">Add company bookmark</button><p class="muted">Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.</p><h3>Device personal bookmarks</h3><p>Authorized administrators can review and remotely edit a device's synchronized personal bookmarks. Changes are applied when the browser next refreshes management state. Titles and URLs are never included in security audit events.</p><div class="row"><select id="bookmark-device" aria-label="Device to review"></select><button id="bookmark-device-load" class="secondary">Read device bookmarks</button></div><p id="bookmark-device-status" role="status" aria-live="polite"></p><div id="bookmark-device-editor" hidden><div class="table-wrap"><table><thead><tr><th>Title</th><th>HTTPS / HTTP URL (no query or fragment)</th><th>Folder</th><th>Actions</th></tr></thead><tbody id="personal-bookmark-rows"></tbody></table></div><div class="row"><button id="personal-bookmark-add" class="secondary">Add personal bookmark</button><button id="bookmark-device-save">Save remote changes</button><button id="bookmark-device-refresh" class="secondary">Discard edits and refresh</button></div><p class="muted">Saving compares the loaded revision so another user's or administrator's updates cannot be overwritten silently.</p></div></section>
|
||||
<section id="teaching" class="card"><p class="eyebrow">TEACH YOUR BROWSER</p><h2>Define what must stay inside</h2><p>Add phrases that must never leave, or register an exact file fingerprint. Reference files stay on your computer; only their SHA-256 fingerprint enters policy.</p><div class="row"><input id="rule-label" placeholder="Rule name"><input id="rule-value" placeholder="Confidential phrase"><button id="add-rule">Add phrase</button></div><label class="file">Register a confidential file fingerprint<input id="fingerprint" type="file"></label><div id="rules"></div><div class="grid"><label class="wide">Try a policy example<textarea id="test-text" placeholder="Test content is sent to your management server, never to an AI provider."></textarea></label></div><button id="test" class="secondary">Test draft policy</button><output id="test-result"></output><h2>Local semantic reference protection</h2><p>Describe a confidential category. Your approved local EmbeddingGemma 2 runtime creates pinned text reference vectors. Document extraction and local OCR text can be compared against these references. This workflow does not train a model or provide native video inspection.</p><div class="grid"><label>Category name<input id="semantic-label" placeholder="Unreleased product designs"></label><label>Similarity threshold<input id="semantic-threshold" type="number" min="0.000001" max="1" step="0.01" value="0.8"></label><label class="wide">Confidential category description<textarea id="semantic-description" placeholder="Describe the protected topic with representative examples."></textarea></label></div><button id="semantic-add" class="secondary">Create semantic reference</button><label>Internal semantic reference document (up to 1 MiB)<input id="semantic-document" type="file"></label><button id="semantic-document-add" class="secondary">Derive local document reference</button><p>Only normalized reference vectors and coverage features enter the policy draft. No document, extracted text or model fine tuning is retained. Complete extraction and chunk coverage are required; publish explicitly to approve.</p><label class="checks"><input id="semantic-enabled" type="checkbox"> Enforce semantic categories</label><div id="semantic-rules"></div><p class="muted">Requires locally provisioned model weights, an exact model revision, matching dimensions and the document-v1 profile. Similarity is a detector score, not a confidence guarantee. Incomplete extraction or chunk coverage always blocks.</p></section>
|
||||
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><div class="wide" id="agent-grants-controls"><h3>Individual agent identities</h3><label>Identity mode<select id="agent-identity-mode"><option value="legacy">Existing shared pairing (legacy)</option><option value="individual">Individual agent grants only</option></select></label><p>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.</p><button type="button" id="agent-grant-add" class="secondary">Add agent grant</button><p id="agent-grants-status" role="status" aria-live="polite"></p><div id="agent-grants-editor"></div><p>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.</p></div><details class="wide"><summary>Advanced source configuration: YARA, Sigma and visual references</summary><p class="muted">Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.</p><label>Security policy JSON<textarea id="security-json" rows="14"></textarea></label><label>Local agent policy JSON<textarea id="agents-json" rows="6" readonly></textarea></label></details><label>AI provider<select id="ai-provider"><optiLine truncated
|
||||
<section class="card" id="security-controls"><h2>Enterprise security and local agents</h2><p>Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.</p><div class="grid"><label class="checks"><input id="security-dns-enabled" type="checkbox"> Enforce DNS blocklists</label><label>Blocked DNS hosts<textarea id="security-dns-hosts" placeholder="*.unapproved.example"></textarea></label><label>Blocked IP addresses<textarea id="security-dns-ips" placeholder="203.0.113.10"></textarea></label><label>Blocked content phrases<textarea id="security-content-phrases" placeholder="One phrase per line"></textarea></label><label class="checks"><input id="security-yara-enabled" type="checkbox"> Require local YARA scanning</label><label>YARA maximum scanned bytes<input id="security-yara-max" type="number" min="1024" max="20971520"></label><label class="checks"><input id="agent-enabled" type="checkbox"> Enable paired local agents</label><label>Agent destination allowlist<textarea id="agent-hosts" placeholder="*.company.example"></textarea></label><label class="checks"><input id="agent-sensitive" type="checkbox"> Permit sensitive page reads within approved scopes</label><fieldset class="wide"><legend>Agent capabilities</legend><div class="checks"><label><input id="agent-scope-sessions" type="checkbox"> Sessions</label><label><input id="agent-scope-navigate" type="checkbox"> Navigate</label><label><input id="agent-scope-back" type="checkbox"> History back</label><label><input id="agent-scope-forward" type="checkbox"> History forward</label><label><input id="agent-scope-reload" type="checkbox"> Reload page</label><label><input id="agent-scope-read" type="checkbox"> Read</label><label><input id="agent-scope-inspect" type="checkbox"> Inspect page controls and values</label><label><input id="agent-scope-scroll" type="checkbox"> Scroll pages</label><label><input id="agent-scope-history:list" type="checkbox"> Read managed browsing history</label><label><input id="agent-scope-history:go" type="checkbox"> Navigate to a history entry</label><label><input id="agent-scope-window:state" type="checkbox"> Read browser window state</label><label><input id="agent-scope-window:control" type="checkbox"> Control browser window</label><label><input id="agent-scope-click" type="checkbox"> Click</label><label><input id="agent-scope-type" type="checkbox"> Type</label><label><input id="agent-scope-select" type="checkbox"> Select approved form option</label><label><input id="agent-scope-check" type="checkbox"> Set checkbox state</label><label><input id="agent-scope-screenshot" type="checkbox"> Screenshot</label><label><input id="agent-scope-ai" type="checkbox"> AI gateway</label><label><input id="agent-scope-tabs:close" type="checkbox"> Close tabs</label><label><input id="agent-scope-bookmarks:list" type="checkbox"> Read bookmarks</label><label><input id="agent-scope-tabs:group" type="checkbox"> Group tabs</label><label><input id="agent-scope-tabs:activate" type="checkbox"> Activate tabs</label><label><input id="agent-scope-bookmarks:mutate" type="checkbox"> Organize user bookmarks</label><label><input id="agent-scope-application:state" type="checkbox"> Application state</label><label><input id="agent-scope-tabs:create" type="checkbox"> Create native tabs</label><label><input id="agent-scope-settings:get" type="checkbox"> Read managed settings</label><label><input id="agent-scope-settings:update" type="checkbox"> Change permitted appearance</label><label><input id="agent-scope-library:organize-plan" type="checkbox"> Propose AI library organization</label><label><input id="agent-scope-library:organize-apply" type="checkbox"> Apply approved library organization</label></div></fieldset><p class="wide" id="agent-navigation-window-help">History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.</p><div class="wide" id="agent-grants-controls"><h3>Individual agent identities</h3><label>Identity mode<select id="agent-identity-mode"><option value="legacy">Existing shared pairing (legacy)</option><option value="individual">Individual agent grants only</option></select></label><p>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.</p><button type="button" id="agent-grant-add" class="secondary">Add agent grant</button><p id="agent-grants-status" role="status" aria-live="polite"></p><div id="agent-grants-ediLine truncated
|
||||
<section class="card" id="document-teaching"><h2>Governed document teaching</h2><p>Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.</p><div class="row"><input id="teach-label" placeholder="Confidential reference label"><input id="teach-file" type="file"><button id="teach-submit">Create reviewed draft</button></div><div id="teach-drafts"></div></section><section class="card" id="password-vault"><h2>Local password vault</h2><p>Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.</p><div class="checks"><label><input id="vault-enabled" type="checkbox"> Enable password vault</label><label><input id="vault-saving" type="checkbox"> Allow saving credentials</label><label><input id="vault-autofill" type="checkbox"> Allow autofill</label></div><label>Idle lock in seconds (60–900)<input id="vault-idle" type="number" min="60" max="900"></label><p>Publish signed policy to apply these controls.</p></section><section class="card" id="visual-protection"><h2>Visual impersonation protection</h2><label class="checks"><input id="visual-enabled" type="checkbox"> Block protected login designs on unauthorized origins</label><p>For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.</p><p>Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.</p><div class="grid"><label>Brand identifier<input id="visual-id" maxlength="80" placeholder="corporate-login"></label><label>Brand label<input id="visual-label" maxlength="100" placeholder="Corporate login"></label><label class="wide">Exact allowed HTTPS origins<textarea id="visual-origins" placeholder="https://login.company.example"></textarea></label><label>Reference scope<select id="visual-scope"><option value="viewport">Whole viewport</option><option value="login-form">Cropped login form</option></select></label><label>Trusted PNG reference<input id="visual-image" type="file" accept="image/png"></label></div><button id="visual-add">Derive reference into draft</button><p>Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.</p></section><section class="card" id="clipboard-controls"><h2>Managed clipboard and ClickFix protection</h2><p>Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.</p><div class="checks"><label><input id="clipboard-enabled" type="checkbox"> Enable clipboard controls</label><label><input id="clipboard-copy" type="checkbox"> Allow copy</label><label><input id="clipboard-cut" type="checkbox"> Allow cut</label><label><input id="clipboard-paste" type="checkbox"> Allow paste</label><label><input id="clipboard-command" type="checkbox"> Block suspicious execution commands</label></div><label>Maximum clipboard text bytes<input id="clipboard-max" type="number" min="1024" max="1048576"></label><p>Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.</p></section><section id="devices" class="card"><div class="section-head"><div><p class="eyebrow">DEVICE FLEET</p><h2>Enrollment and access</h2><p>Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.</p></div></div><div class="row"><input id="enroll-user" placeholder="Employee identity / asset owner"><button id="enroll">Create one-use enrollment code</button></div><output id="enrollment"></output><div class="row filter-row"><label>Find a device<input id="device-filter" type="search" placeholder="Name, identity or platform"></label><label>Access<select id="device-access-filter"><option value="all">All devices</option><option value="active">Active</option><option value="revoked">Revoked</Line truncated
|
||||
<section id="access-sessions" class="card"><p class="eyebrow">ADMINISTRATOR ACCESS</p><h2>Active management sessions</h2><p>Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.</p><button id="sessions-load" class="secondary">Refresh active sessions</button><p id="sessions-status" role="status" aria-live="polite">Session inventory requires the administrator role.</p><div class="table-wrap"><table><thead><tr><th>Identity</th><th>Role</th><th>Expires</th><th>Action</th></tr></thead><tbody id="session-rows"></tbody></table></div></section><section id="incidents" class="card"><p class="eyebrow">SECURITY OPERATIONS</p><h2>Incident triage</h2><p>Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.</p><div class="row filter-row"><label>Status<select id="incident-status-filter"><option value="">All statuses</option><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity-filter"><option value="">All severities</option><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><button id="incidents-refresh" class="secondary">Refresh incidents</button></div><p id="incidents-status" role="status" aria-live="polite"></p><div class="table-wrap"><table><thead><tr><th>Created</th><th>Security event</th><th>Status</th><th>Severity</th><th>Assignee</th><th>Review</th></tr></thead><tbody id="incident-rows"></tbody></table></div><button id="incidents-more" class="secondary" hidden>Load older incidents</button><div id="incident-detail" hidden><div class="section-head"><h3 id="incident-title">Incident</h3><button id="incident-detail-refresh" class="secondary">Discard edits and refresh</button></div><p id="incident-revision" class="muted"></p><pre id="incident-event"></pre><section id="incident-evidence" class="evidence-viewer" hidden aria-labelledby="evidence-title"><div class="section-head"><h3 id="evidence-title">Device-reported protected evidence</h3><button id="evidence-close" class="secondary">Close and clear</button></div><label>Access purpose<select id="evidence-reason"><option value="">Choose a purpose</option><option value="incident-response">Incident response</option><option value="investigation">Investigation</option><option value="compliance">Compliance</option></select></label><div id="evidence-rows"></div><p id="evidence-status" role="status" aria-live="polite"></p><pre id="evidence-plaintext" hidden></pre></section><div class="grid"><label>Investigation status<select id="incident-state"><option>open</option><option>investigating</option><option>resolved</option><option>dismissed</option></select></label><label>Severity<select id="incident-severity"><option>low</option><option>medium</option><option>high</option><option>critical</option></select></label><label>Assignee<input id="incident-assignee" maxlength="160" placeholder="Team or analyst identity"></label><label class="wide">Investigation note<textarea id="incident-comment" maxlength="1000" placeholder="Metadata and investigation outcome only; do not paste secrets."></textarea></label></div><button id="incident-save">Save investigation update</button><p id="incident-detail-status" role="status" aria-live="polite"></p><h3>Investigation history</h3><div id="incident-history"></div></div></section><section class="card" id="audit-retention"><p class="eyebrow">AUDIT LIFECYCLE</p><h2>Database event retention</h2><p id="audit-retention-status" role="status">Loading deployment configuration.</p><p class="muted">Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.</p></section><section id="events" class="card"><p class="eyebrow">SECURITY MONITORING</p><h2>Recent audit events</h2><div class="row"><button id="export-ecs" class="secondary">Export ECS JSON</button><button id="export-cef" class="secondary">Export CEF</button></div><div class="row filter-row"><label>Find an event<input id="event-filter" type="search" placeholder="Event type, device or metadata"></label><label>Decision<select id="event-decision-filter"><option value="all">All events</option><option value="block">Blocks</option><option value="allow">Allows</option></select></label><output id="event-filter-count" aria-live="polite"></output></div><div class="table-wrap"><table><thead><tr><th>Time</th><th>Event</th><th>Device</th><th>Metadata</th></tr></thead><tbody id="event-rows"></tbody></table></div><p class="muted">This view shows the latest 100 eventsLine truncated
|
||||
<section class="card" id="company-branding"><p class="eyebrow">COMPANY EXPERIENCE</p><h2>Company logo and start page</h2><p>Roll out your company identity through signed policy. Name and accent above apply alongside this logo.</p><div class="grid"><label>Company logo (PNG, up to 128 KiB)<input id="brand-logo-file" type="file" accept="image/png"></label><div><img id="brand-logo-preview" alt="Company logo preview" width="160" height="80" hidden><button id="brand-logo-remove" type="button" class="secondary">Remove logo</button><output id="brand-logo-status" aria-live="polite"></output></div><label>Start-page heading<input id="startpage-title" maxlength="100" placeholder="Welcome"></label><label>Start-page message<input id="startpage-subtitle" maxlength="240" placeholder="Your company workspace"></label><label><input id="startpage-news-enabled" type="checkbox"> Show company news</label><label>Company RSS / Atom feed (HTTPS)<input id="startpage-feed-url" type="url" placeholder="https://intranet.company.example/news.xml"></label></div><p>Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.</p></section><section class="card" id="download-scan-controls"><h2>Download inspection</h2><div class="checks"><label><input id="download-document-dlp" type="checkbox"> Require complete document DLP for incoming downloads</label><label><input id="yara-office-macros" type="checkbox"> Scan extracted Office VBA with enabled central YARA rules</label></div><p>Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.</p></section><section id="audit-integrity" class="card" hidden><p class="eyebrow">AUDIT INTEGRITY</p><div class="section-head"><h2>Verify security audit records</h2><button id="audit-integrity-verify" type="button" class="secondary">Verify stored records</button></div><p>Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.</p><p id="audit-integrity-status" class="muted" aria-live="polite">Not verified in this session.</p><div id="audit-integrity-details" style="overflow-wrap:anywhere"></div></section><section id="evidence-policy-controls" class="card"><p class="eyebrow">PROTECTED SECURITY EVIDENCE</p><h2>Protected evidence collection</h2><p>Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.</p><div class="checks"><label><input id="evidence-policy-enabled" type="checkbox"> Enable protected evidence recording</label><label><input id="evidence-policy-downloads" type="checkbox"> Automatically capture inspected download metadata</label><label><input id="evidence-policy-interactions" type="checkbox"> Capture clipboard, agent-click and observed page-gesture metadata</label></div><p>Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.</p><p>Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, action and bounded target metadata. Guarded clipboard and agent effects are marked completed; trusted foreground page clicks and activated form events are marked observed, which does not prove network transmission. Sensitive forms, page paths, values, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Collection is bounded and limited to the active main-frame page; it is not a complete replay. Publish and review this draft through the normal policy workflow.</p></section><section id="diagnostics-controls" class="card"><p class="eyebrow">OBSERVABILITY</p><h2>Browser OpenTelemetry</h2><p>Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.</p><div class="grid"><label><input id="diagnostics-enabled" type="checkbox"> Enable browser diaLine truncated
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Managed agent history and window controls
|
||||
|
||||
Administrators independently enable `history:list`, `history:go`, `window:state`
|
||||
and `window:control` in the signed baseline and approved individual grant. All
|
||||
four start unchecked. User-profile restrictions, global agent disable, grant
|
||||
expiry, credential generation and destination restrictions continue to apply.
|
||||
Use the existing locally paired DPAPI/PQ agent client; there is no new plaintext
|
||||
or unauthenticated endpoint.
|
||||
|
||||
| Action | Arguments | Returned data |
|
||||
| --- | --- | --- |
|
||||
| `history:list` | Native `sessionId` | `snapshotId`, entries with opaque `entryId`, sanitized title, optional inspected URL and active flag |
|
||||
| `history:go` | Same `sessionId`, returned `snapshotId` and `entryId` | `completed` |
|
||||
| `window:state` | Empty object | Bounded dimensions and visible/focused/minimized/maximized/fullScreen booleans |
|
||||
| `window:control` | `command`: minimize, maximize, restore or focus | `completed`; query state separately |
|
||||
|
||||
For history navigation, first request a list, select a returned entry and use its
|
||||
handles within 60 seconds. The snapshot is consumed once. Refresh the list after
|
||||
navigation or a stale-snapshot refusal; do not automatically repeat a mutation.
|
||||
Agents never supply a raw native history index or URL to the history-go action.
|
||||
|
||||
History is limited to 100 entries and current native tab ownership. Complete DLP
|
||||
inspection precedes title/URL export. Credential-bearing entries are masked and
|
||||
cannot be selected for navigation, even with sensitive-read permission. Other
|
||||
classified confidential entries retain opaque handles while hiding their title
|
||||
and URL. Entries outside the actor's destination scope are excluded.
|
||||
|
||||
Private snapshots bind the actor/generation, session, native contents, policy,
|
||||
navigation token and complete bounded inventory. Native Chromium `pageState`
|
||||
can contain form values: it is never exported or retained in the snapshot.
|
||||
Only a private comparison checksum is used to detect replacement even when URL
|
||||
and title stay the same; this checksum also never leaves the privileged process.
|
||||
The final native comparison occurs immediately before dispatch. Scroll/form-state
|
||||
updates can legitimately make a snapshot stale. This is active-tab history
|
||||
navigation, not persistent session restoration.
|
||||
|
||||
Window control operates only on S Browser's own window. It rechecks policy,
|
||||
principal authorization, cancellation and native window identity before effects
|
||||
and before returning state. Closing the application, arbitrary resizing,
|
||||
positioning and general OS automation are not provided by this scope. A failure
|
||||
after an effect does not imply undo, and never triggers a retry.
|
||||
|
||||
Every action retains durable pre-execution and completion/failure audit with
|
||||
verified actor attribution. Ordinary events contain no history titles, URLs,
|
||||
serialized page state or arguments. Debug spans name the action and native
|
||||
navigation/window phase under the protected RPC parent. Administrators retain
|
||||
central control of diagnostic export independently of these capabilities.
|
||||
|
||||
Actual Windows/Electron proof exercises two individually paired actors,
|
||||
cross-actor/stale/replayed history refusal, DLP/credential masking, host exclusions,
|
||||
one exact indexed navigation, native maximize/restore/minimize/focus, targeted
|
||||
revocation and global disable. Named OTLP stages correlate with completed audit
|
||||
events (`artifacts/extended-agent-controls-native-proof.json`). Same-URL/title
|
||||
page-state replacement has unit-boundary evidence; a separate native mutation
|
||||
experiment is not claimed. Same-user Windows DPAPI trust and production identity
|
||||
custody remain separate limitations.
|
||||
@@ -34,3 +34,13 @@ requests, including durations and named code stages. Debug collection is central
|
||||
managed, bounded and disableable. Operational spans do not silently become a raw
|
||||
content or credential archive. Backend tracing and collector/SIEM permissions,
|
||||
encryption and retention must complete the end-to-end pipeline.
|
||||
|
||||
## Current implementation evidence
|
||||
|
||||
Earlier status sentences above are superseded by individual DPAPI/PQ identities,
|
||||
targeted revocation, scoped library/bookmark controls, protected download/gesture
|
||||
metadata and the history/window capabilities described in
|
||||
[agent-history-window.md](agent-history-window.md). The broader requirements remain.
|
||||
History and window debug stages are now named children of completed protected RPC
|
||||
actions, with durable actor attribution. Full replay/content capture, safe visual
|
||||
export, persistent restoration and broader secured administration are still open.
|
||||
@@ -564,3 +564,48 @@ tests pass (`artifacts/human-windows-build.log`, `human-package-proof.log`,
|
||||
These require independent deployment evidence, not only mocked local tests.
|
||||
|
||||
Mac/Linux/mobile remain explicitly deferred by the user's Windows-first scope.
|
||||
|
||||
## Secured history and window agent capabilities
|
||||
|
||||
Four separately signed, default-unchecked capabilities now cover inspected
|
||||
history listing, opaque snapshot navigation and bounded native window state/control.
|
||||
Baseline, profile and individual-grant restrictions apply together. Native history
|
||||
output excludes pageState; private bounded comparison checksums detect changed
|
||||
state without retaining or exporting form values. Snapshots expire, are actor-
|
||||
and session-bound and consumed once. Credential-bearing entries are masked and
|
||||
cannot navigate; destination scope filters apply before metadata export and
|
||||
immediately before native navigation. See `docs/agent-history-window.md`.
|
||||
|
||||
Actual Windows/Electron proof covers two individual actors, host exclusions,
|
||||
cross-actor/replay/stale denial, one exact indexed target, native maximize,
|
||||
minimize, restore and focus, targeted revocation and global disable. Completed
|
||||
history trace `d331f2f223782a94c9b99b7b6c7c6b6c` contains its named native navigation
|
||||
child; window trace `2fc7b43bea197f4bd7fada08b81ed30e` contains its named window child.
|
||||
Missing action names were found in the diagnostic allowlist and corrected rather
|
||||
than relying on generic dispatch timing. Ordinary audit excludes content and
|
||||
native state (`artifacts/extended-agent-controls-native-proof.json`).
|
||||
|
||||
Central admin controls and individual grant defaults were verified in the actual
|
||||
UI. The fixture now records real network outcomes and requires correlated backend
|
||||
spans for every completed request and all mutations. 148 completed requests and
|
||||
seven mutation attempts have trace evidence; three failed GET attempts are
|
||||
reported separately. An earlier attempted request's missing trace is absent even
|
||||
from protected archives and remains unproven, not retroactively passed. No
|
||||
enforcement assertion was relaxed (`artifacts/control-scopes-admin-ui-current.log`).
|
||||
|
||||
The current full suite passes 569 tests. Native browser and syntax checks pass.
|
||||
These controls do not complete full first-class agent access: persistent session
|
||||
restoration, safely inspected screenshots, vault/file/admin operations, arbitrary
|
||||
widgets and worker ownership remain open, together with the production/integration
|
||||
limits listed above. Same-state native page-state mutation is unit-proven only.
|
||||
|
||||
Final current Windows build and packaged-source verification pass, including the
|
||||
new history-state and window-control modules, contained scanners, offline semantic
|
||||
inference, upload leases and both shipped agent clients. Syntax and the native
|
||||
tab/DLP/revocation proof pass. Logs: `artifacts/extended-controls-windows-build.log`,
|
||||
`extended-controls-package-proof.log`, `extended-controls-syntax.log`,
|
||||
`extended-controls-native-tabs.log`. Executable Authenticode remains `NotSigned`.
|
||||
Next control milestones are persistent session restoration and scoped vault/file
|
||||
operations; history inventory/navigation and the four bounded window operations
|
||||
are now implemented, rather than remaining wholly absent. The overall objective
|
||||
remains active and unachieved.
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
const {z}=require('zod');
|
||||
const application=['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=['history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
|
||||
const AGENT_SCOPES=Object.freeze({sessions:'sessions:list',navigate:'browser:navigate',back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate',read:'browser:read',inspect:'browser:read',scroll:'browser:interact',click:'browser:interact',type:'browser:interact',select:'browser:interact',check:'browser:interact',ai:'ai:request',...Object.fromEntries(application.map(action=>[action,action])),'agent-request':'agent:request'});
|
||||
const AgentAuditSchema=z.object({action:z.enum(Object.keys(AGENT_SCOPES)),scope:z.enum([...new Set(Object.values(AGENT_SCOPES))]),outcome:z.enum(['authorized','completed','denied','failed']),sessionId:z.string().uuid().optional(),principalId:z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/).optional(),credentialGeneration:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional()}).strict().superRefine((value,ctx)=>{
|
||||
if((value.principalId!==undefined)!==(value.credentialGeneration!==undefined))ctx.addIssue({code:'custom',message:'Agent principal attribution requires both identity and generation'});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { isIP } from 'node:net';
|
||||
export const AgentScopes=['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=['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();
|
||||
|
||||
@@ -7,13 +7,15 @@ async function approve(version){assert.equal(await evaluate(`document.getElement
|
||||
async function capture(file,section){await evaluate(`document.getElementById('${section}').scrollIntoView();document.getElementById('console-theme').value='dark';document.getElementById('console-theme').dispatchEvent(new Event('change'))`);await new Promise(r=>setTimeout(r,250));fs.mkdirSync('artifacts',{recursive:true});fs.writeFileSync(file,(await window.webContents.capturePage()).toPNG());}
|
||||
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=[];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');proofTraceIds.push({traceId,parentSpanId:spanId});callback({requestHeaders:{...details.requestHeaders,traceparent:'00-'+traceId+'-'+spanId+'-01'}});});
|
||||
await identity('grant-author');assert.equal(await evaluate('typeof require'),'undefined');await evaluate(`document.getElementById('agent-enabled').checked=true;document.getElementById('agent-scope-sessions').checked=true;document.getElementById('agent-scope-read').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();`);
|
||||
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'])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;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);
|
||||
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'].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'])assert.ok(diff.includes(text),text);await capture('artifacts/agent-grants-review-admin.png','independent-review-detail');await approve(2);
|
||||
await identity('grant-auditor');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');
|
||||
fs.writeFileSync('artifacts/agent-grants-admin-request-trace-ids.json',JSON.stringify(proofTraceIds));console.log('GRANT_ADMIN_OK');clearTimeout(deadline);window.destroy();app.exit(0);
|
||||
}).catch(error=>{console.error('GRANT_ADMIN_FAILED',error.message);clearTimeout(deadline);window?.destroy();app.exit(1);});
|
||||
@@ -17,5 +17,5 @@ try {
|
||||
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');
|
||||
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=[];for(const item of traces){const r=await fetch(`http://127.0.0.1:4318/api/trace/${item.traceId}`);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,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified');
|
||||
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,separateAuthorReviewer:true,selfApprovalDenied:true,auditorReadonly:true,exactGrantDiff:true,rotationReviewed:true,historyDraftPreserved:true,restoreReviewed:true,finalVersion:p.version,completedRequests:traces.filter(item=>item.outcome==='completed').length,failedAttempts:failedAttempts.map(item=>({traceId:item.traceId,outcome:item.outcome,method:item.method,route:item.route})),traceIds:exported.map(x=>x.traceId)},null,2));console.log('Grant publication/history and exported traces verified');
|
||||
} finally {child?.kill();if(server)await new Promise(r=>{server.closeAllConnections?.();server.close(r);});store?.db.close();const target=resolve(directory);if(!target.startsWith(resolve(tmpdir())+sep)||!basename(target).startsWith('safetynet-agent-grant-admin-'))throw Error('Unsafe cleanup');rmSync(target,{recursive:true,force:true,maxRetries:10,retryDelay:100});}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
const {app,BrowserWindow}=require('electron'),fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
|
||||
app.setPath('userData',process.env.SAFETYNET_SMOKE_USERDATA);app.disableHardwareAcceleration();require('../apps/browser/main.cjs');
|
||||
const wait=ms=>new Promise(r=>setTimeout(r,ms)),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('Extended native fixture timed out');}
|
||||
const overview=async()=> (await fetch(process.env.SAFETYNET_SMOKE_SERVER+'/api/admin/overview',{headers})).json();
|
||||
app.whenReady().then(async()=>{let page;try{
|
||||
const win=await until(()=>{const w=BrowserWindow.getAllWindows()[0];return w&&!w.webContents.isLoading()&&w.webContents.getURL().endsWith('index.html')&&w;});
|
||||
assert.equal((await win.webContents.executeJavaScript(`window.safetynet.enroll(${JSON.stringify({server:process.env.SAFETYNET_SMOKE_SERVER,code:process.env.SAFETYNET_SMOKE_CODE})})`)).ok,true);
|
||||
page=win.contentView.children.find(v=>v.webContents!==win.webContents).webContents;await until(()=>!page.isLoading());
|
||||
const pair=async id=>{const r=await win.webContents.executeJavaScript(`window.safetynet.agentConnection(${JSON.stringify(id)})`);assert.equal(r.ok,true);return r.data;};const a=await pair('reader-a'),b=await pair('state-b');
|
||||
const requestFile=path.join(app.getPath('userData'),'extended-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 sessionId=(await call(a,'sessions')).result[0].id,base=process.env.SAFETYNET_SMOKE_SINK;
|
||||
assert.equal((await win.webContents.executeJavaScript(`window.safetynet.navigate(${JSON.stringify(process.env.SAFETYNET_SMOKE_FORBIDDEN)})`)).ok,true);await until(()=>!page.isLoading());
|
||||
for(const route of ['/old','/protected-title','/?code=synthetic-private','/current']){assert.equal((await call(a,'navigate',{sessionId,url:base+route})).exit,0);await until(()=>!page.isLoading());}
|
||||
let list=await call(a,'history:list',{sessionId});assert.equal(list.exit,0);const entries=list.result.entries;
|
||||
assert.ok(entries.some(e=>e.title==='Protected page'&&!e.url),'Confidential/credential history was not masked');assert.ok(!entries.some(e=>e.url?.includes('127.0.0.2')),'Out-of-host native history exposed');assert.ok(!JSON.stringify(entries).includes('synthetic-private'));assert.ok(!JSON.stringify(entries).includes('Synthetic confidential inventory title'));
|
||||
assert.equal((await call(b,'history:go',{sessionId,snapshotId:list.result.snapshotId,entryId:entries[0].entryId})).exit,1,'Other actor reused snapshot');
|
||||
const currentIndex=entries.findIndex(e=>e.active),credential=entries[currentIndex-1];assert.equal(credential.title,'Protected page');assert.equal(credential.url,undefined);assert.equal((await call(a,'history:go',{sessionId,snapshotId:list.result.snapshotId,entryId:credential.entryId})).exit,1);
|
||||
// The failed credential selection does not consume another entry. Obtain fresh
|
||||
// grant so the exact public native target can be proved independently.
|
||||
list=await call(a,'history:list',{sessionId});const old=list.result.entries.find(e=>e.url===base+'/old');assert.ok(old);
|
||||
let navigations=0;const observe=(_e,url)=>{if(url===base+'/old')navigations++;};page.on('did-navigate',observe);
|
||||
assert.equal((await call(a,'history:go',{sessionId,snapshotId:list.result.snapshotId,entryId:old.entryId})).exit,0);await until(()=>page.getURL()===base+'/old'&&!page.isLoading());assert.equal(navigations,1);
|
||||
assert.equal((await call(a,'history:go',{sessionId,snapshotId:list.result.snapshotId,entryId:old.entryId})).exit,1);assert.equal(navigations,1);page.removeListener('did-navigate',observe);
|
||||
list=await call(a,'history:list',{sessionId});assert.equal((await call(a,'navigate',{sessionId,url:base+'/replacement'})).exit,0);await until(()=>!page.isLoading());assert.equal((await call(a,'history:go',{sessionId,snapshotId:list.result.snapshotId,entryId:list.result.entries[0].entryId})).exit,1);
|
||||
const initial=(await call(b,'window:state')).result;assert.equal(typeof initial.width,'number');assert.equal((await call(a,'window:control',{command:'maximize'})).exit,1);
|
||||
for(const command of ['maximize','restore','minimize','restore','focus']){if(command==='focus'){win.blur();await until(()=>!win.isFocused());}assert.equal((await call(b,'window:control',{command})).exit,0);await wait(150);if(command==='maximize')assert.equal(win.isMaximized(),true);if(command==='minimize')assert.equal(win.isMinimized(),true);if(command==='restore')await until(()=>!win.isMinimized()&&!win.isMaximized());if(command==='focus')await until(()=>win.isFocused());}
|
||||
assert.equal(win.isMinimized(),false);
|
||||
const change=async modify=>{const p=(await overview()).policy;modify(p);p.version++;assert.equal((await fetch(process.env.SAFETYNET_SMOKE_SERVER+'/api/admin/policy',{method:'PUT',headers,body:JSON.stringify(p)})).status,200);await until(async()=>{const s=await win.webContents.executeJavaScript('window.safetynet.state()');return s.data.ready&&s.data.policyVersion===p.version;});};
|
||||
await change(p=>{p.agents.grants.find(g=>g.id==='state-b').enabled=false;});assert.equal((await call(b,'window:control',{command:'maximize'})).exit,1);assert.equal(win.isMaximized(),false);
|
||||
await change(p=>{p.agents.enabled=false;});assert.equal((await call(a,'history:list',{sessionId})).exit,1);assert.equal((await call(a,'window:state')).exit,1);
|
||||
const events=await until(async()=>{const e=(await overview()).events.filter(e=>e.kind==='agent.action');return e.some(e=>e.metadata.agent?.action==='history:go')&&e.some(e=>e.metadata.agent?.action==='window:control')&&e;});
|
||||
for(const event of events){const encoded=JSON.stringify(event);for(const text of ['synthetic-private','Synthetic confidential inventory title','pageState',base+'/old'])assert.ok(!encoded.includes(text),'Ordinary audit contains protected history content');}
|
||||
await wait(1500);const traceProof=[];
|
||||
for(const action of ['history:go','window:control']){const event=events.find(e=>e.metadata.agent?.action===action&&e.metadata.agent.outcome==='completed'&&e.metadata.traceId);assert.ok(event);const response=await fetch('http://127.0.0.1:4318/api/trace/'+event.metadata.traceId);assert.equal(response.ok,true);const trace=await response.json(),spans=trace.spans||trace,root=spans.find(s=>s.operation==='agent.request');assert.ok(root);const dispatch=spans.find(s=>s.operation==='agent.dispatch'&&s.parentSpanId===root.spanId);assert.ok(dispatch);const agentAction=spans.find(s=>s.name==='browser.agent'&&s.operation===action&&s.parentSpanId===dispatch.spanId);assert.ok(agentAction,'Missing named agent action');assert.ok(spans.some(s=>s.name===(action==='history:go'?'browser.navigation':'browser.window')&&s.operation===action&&s.parentSpanId===agentAction.spanId),'Missing named native stage');traceProof.push({action,principalId:event.metadata.agent.principalId,traceId:event.metadata.traceId,stages:spans.map(s=>({name:s.name,operation:s.operation,parentSpanId:s.parentSpanId,spanId:s.spanId,durationMs:s.durationMs}))});}
|
||||
const health=await (await fetch('http://127.0.0.1:4318/health')).json();assert.equal(health.rejectedWrites,0);const summary=await (await fetch('http://127.0.0.1:4318/api/summary')).json();
|
||||
fs.writeFileSync(path.join(process.env.SAFETYNET_SMOKE_ARTIFACTS,'extended-agent-controls-native-proof.json'),JSON.stringify({actualElectron:true,twoActors:true,historyMetadataMasked:true,outOfHostHistoryExcluded:true,crossActorGrantDenied:true,indexedNavigationExactlyOnce:true,snapshotReplayDenied:true,staleSnapshotDenied:true,nativeWindowCommands:true,nativeRestorePostcondition:true,nativeFocusAfterDeliberateBlur:true,targetedRevokeBeforeEffect:true,globalDisable:true,ordinaryAuditContentFree:true,health:{storedBytes:health.storedBytes,rejectedWrites:health.rejectedWrites,counts:health.counts},summary,traceProof},null,2));
|
||||
console.log('Extended agent controls native proof passed');require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(0);
|
||||
}catch(error){console.error(error.stack);if(page)require('../apps/browser/page-clipboard-guard.cjs').disposePageClipboardGuard(page);app.exit(1);}});
|
||||
@@ -0,0 +1,30 @@
|
||||
import {createServer} from 'node:http';
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdtempSync,mkdirSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join,resolve} 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-extended-'));mkdirSync('artifacts',{recursive:true});
|
||||
if(!resolve(temporary).startsWith(resolve(tmpdir())+requireSeparator())||!temporary.split(/[\\/]/).at(-1).startsWith('safetynet-extended-'))throw Error('Unsafe temporary fixture path');
|
||||
function requireSeparator(){return process.platform==='win32'?'\\':'/';}
|
||||
const store=openStore(join(temporary,'db')),code=randomBytes(32).toString('base64url'),adminToken=randomBytes(32).toString('base64url');
|
||||
let forbiddenRequests=0,ownerlessRequests=0;const forbidden=createServer((_req,res)=>{forbiddenRequests++;res.end('Forbidden receiver');}).listen(0,'127.0.0.2');await new Promise(r=>forbidden.once('listening',r));
|
||||
const forbiddenUrl=`http://127.0.0.2:${forbidden.address().port}/forbidden`;
|
||||
const sink=createServer((req,res)=>{if(req.url==='/count'){res.setHeader('content-type','application/json');res.end(JSON.stringify({forbiddenRequests,ownerlessRequests}));return;}if(req.url==='/ownerless')ownerlessRequests++;if(req.url==='/redirect'){res.writeHead(302,{location:forbiddenUrl});res.end();return;}res.setHeader('content-type','text/html');res.end(req.url==='/protected-title'?'<!doctype html><title>Synthetic confidential inventory title</title><h1>Public actor fixture</h1>':'<!doctype html><title>Individual actor fixture</title><h1>Public actor fixture</h1>');}).listen(0,'127.0.0.1');await new Promise(r=>sink.once('listening',r));
|
||||
const p=store.get('policy');p.browser.homeUrl=`http://127.0.0.1:${sink.address().port}`;
|
||||
p.agents={...p.agents,enabled:true,identityMode:'individual',scopes:['sessions','navigate','history:list','history:go','window:state','window:control'],allowedHosts:['127.0.0.1','127.0.0.2'],grants:[{id:'reader-a',name:'Reader A',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['sessions','navigate','history:list','history:go','window:state'],allowedHosts:['127.0.0.1'],generation:1},{id:'state-b',name:'State B',enabled:true,expiresAt:new Date(Date.now()+3600000).toISOString(),scopes:['history:list','history:go','window:state','window:control'],allowedHosts:['127.0.0.1'],generation:1}]};
|
||||
p.dlp.rules.push({id:'inventory-confidential-fixture',label:'Synthetic inventory fixture',action:'block',kind:'literal',value:'Synthetic confidential inventory title'});
|
||||
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,'individual-fixture');
|
||||
const config={secure:false,publicUrl:'http://127.0.0.1',adminToken,requirePQManagement: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 electron=createRequire(import.meta.url)('electron');
|
||||
async function run(phase){const child=spawn(electron,['scripts/extended-agent-controls-smoke.cjs'],{windowsHide:true,stdio:'inherit',env:{...process.env,SAFETYNET_SMOKE_SERVER:config.publicUrl,SAFETYNET_SMOKE_CODE:code,SAFETYNET_SMOKE_ADMIN:adminToken,SAFETYNET_SMOKE_USERDATA:join(temporary,'browser'),SAFETYNET_SMOKE_ARTIFACTS:resolve('artifacts'),SAFETYNET_SMOKE_PHASE:phase,SAFETYNET_SMOKE_SINK:p.browser.homeUrl,SAFETYNET_SMOKE_FORBIDDEN:forbiddenUrl}});const deadline=setTimeout(()=>child.kill(),180000);const exit=await new Promise(r=>{child.once('exit',v=>r(v??1));child.once('error',()=>r(1));});clearTimeout(deadline);return exit;}
|
||||
let exit=await run('initial');
|
||||
for(const server of [management,sink,forbidden])server.closeAllConnections();await Promise.all([new Promise(r=>management.close(r)),new Promise(r=>sink.close(r)),new Promise(r=>forbidden.close(r))]);store.db.close();await telemetry?.shutdown();rmSync(temporary,{recursive:true,force:true});process.exit(exit);
|
||||
@@ -14,7 +14,7 @@ assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize
|
||||
for(const file of ['apps/browser/upload-lease.cjs','apps/browser/file-upload-gate.cjs','apps/browser/request-source.cjs'])assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize(file)),await fs.readFile(file),`Packaged upload binding source is stale: ${file}`);
|
||||
assert.deepEqual(await fs.readFile(path.join(root,'resources/inspector/upload_lease.py')),await fs.readFile('apps/inspector/upload_lease.py'),'Packaged Windows upload lease helper is stale');
|
||||
assert.deepEqual(extractFile(path.join(root,'resources/app.asar'),path.normalize('apps/browser/header-inspection.cjs')),await fs.readFile('apps/browser/header-inspection.cjs'),'Packaged outgoing header DLP source is stale');
|
||||
for(const file of ['apps/browser/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/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}`);
|
||||
|
||||
@@ -4,9 +4,11 @@ import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import {DiagnosticsSchema} from '../packages/core/diagnostics.mjs';
|
||||
const html=readFileSync(new URL('../apps/server/public/index.html',import.meta.url),'utf8');
|
||||
test('every centrally offered agent scope has an accessible structured control',()=>{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 scopes)assert.ok(html.includes(`id="agent-scope-${scope}"`),scope);for(const scope of ['inspect','scroll','library:organize-plan','library:organize-apply'])assert.ok(scopes.includes(scope));});
|
||||
test('every centrally offered agent scope has an accessible structured control',()=>{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 scopes)assert.ok(html.includes(`id="agent-scope-${scope}"`),scope);for(const scope of ['inspect','scroll','library:organize-plan','library:organize-apply','history:list','history:go','window:state','window:control'])assert.ok(scopes.includes(scope));});
|
||||
test('diagnostic admin roundtrip retains bounded export settings and denies auditors',()=>{const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{value:'',checked:false,disabled:false,addEventListener(){}});return fields.get(id);};let p={diagnostics:DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:'https://collector.example',maxQueueSize:123,flushIntervalMs:333,maxRecordAgeMs:4567,exportTimeoutMs:999})};const context=vm.createContext({$:get,fill(){},draft:()=>structuredClone(p),applyOperatorPermissions(){},independentApprovalRequired:true,policy:null,currentPrincipal:null,updateDraftStatus(){}});vm.runInContext(readFileSync(new URL('../apps/server/public/diagnostics-admin.js',import.meta.url),'utf8'),context);context.fill(p);assert.deepEqual(DiagnosticsSchema.parse(context.draft().diagnostics),p.diagnostics);context.applyOperatorPermissions({role:'auditor',permissions:['policy.read']});assert.equal(get('diagnostics-enabled').disabled,true);context.applyOperatorPermissions({role:'operator',permissions:['policy.prepare']});assert.equal(get('diagnostics-enabled').disabled,false);get('diagnostics-enabled').checked=false;assert.equal(DiagnosticsSchema.parse(context.draft().diagnostics).enabled,false);});
|
||||
test('SIEM control is administrator-only and writes the loaded revision',async()=>{const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{value:'',checked:false,disabled:false,hidden:false,textContent:'',addEventListener(type,fn){this.listener=fn;}});return fields.get(id);};const calls=[],context=vm.createContext({$:get,applyOperatorPermissions(){},load:async()=>{},task:fn=>fn,currentPrincipal:null,api:async(path,body,method)=>{calls.push({path,body,method});return {enabled:body?.enabled??true,revision:8,format:'ecs',pending:2,failures:0,configured:true};}});vm.runInContext(readFileSync(new URL('../apps/server/public/siem-admin.js',import.meta.url),'utf8'),context);context.currentPrincipal={role:'auditor',permissions:['policy.read']};context.applyOperatorPermissions(context.currentPrincipal);assert.equal(get('siem-controls').hidden,true);assert.equal(calls.length,0);await assert.rejects(get('siem-save').listener(),/Administrator/);context.currentPrincipal={role:'admin',permissions:['policy.read']};context.fillSIEMExport({enabled:true,revision:7,format:'cef'});context.applyOperatorPermissions(context.currentPrincipal);get('siem-enabled').checked=false;await get('siem-save').listener();assert.equal(calls[0].path,'/api/admin/siem');assert.equal(calls[0].method,'PUT');assert.equal(calls[0].body.enabled,false);assert.equal(calls[0].body.expectedRevision,7);});
|
||||
|
||||
import {FileTransfersSchema} from '../packages/core/file-transfer.mjs';
|
||||
test('download scan settings remain explicit and cannot alter upload policy',()=>{const fields=new Map(),get=id=>{if(!fields.has(id))fields.set(id,{checked:false,disabled:false});return fields.get(id);};const policy={fileTransfers:FileTransfersSchema.parse({upload:{blockUnknown:true},download:{}}),security:{yara:{enabled:true,rules:[],scanOfficeMacros:true}}};const context=vm.createContext({$:get,fill(){},draft:()=>structuredClone(policy),applyOperatorPermissions(){},independentApprovalRequired:true,policy:null,currentPrincipal:null});vm.runInContext(readFileSync(new URL('../apps/server/public/download-scan-admin.js',import.meta.url),'utf8'),context);context.fill(policy);assert.equal(get('download-document-dlp').checked,true);get('download-document-dlp').checked=false;get('yara-office-macros').checked=false;const updated=context.draft();assert.equal(FileTransfersSchema.parse(updated.fileTransfers).download.requireDocumentDlp,false);assert.deepEqual(updated.fileTransfers.upload,policy.fileTransfers.upload);assert.equal(updated.security.yara.scanOfficeMacros,false);context.applyOperatorPermissions({role:'auditor',permissions:['policy.read']});assert.equal(get('yara-office-macros').disabled,true);});
|
||||
|
||||
test('new history and window capability controls require explicit opt-in and explain limits',()=>{for(const scope of ['history:list','history:go','window:state','window:control']){const tag=html.match(new RegExp(`<input[^>]*id="agent-scope-${scope}"[^>]*>`))[0];assert.equal(/\bchecked\b/.test(tag),false);}assert.ok(html.includes('History read and history navigation are separate capabilities'));assert.ok(html.includes('never grants OS or administrator access'));const editor=readFileSync(new URL('../apps/server/public/agent-grants-admin.js',import.meta.url),'utf8');assert.ok(editor.includes('enabled:false'));assert.ok(editor.includes('scopes:[]'));assert.ok(editor.includes('check.disabled=!agentGrantsEditable'));});
|
||||
@@ -12,6 +12,16 @@ async function fixture(t, overrides = {}) {
|
||||
const request = (action,args={},headers={})=>requestAgent(api,{action,args},{headers});
|
||||
return {api, request,calls,events};
|
||||
}
|
||||
|
||||
test('new history and window actions use protected transport, strict arguments and projected outputs',async t=>{
|
||||
const snapshotId='00000000-0000-4000-8000-000000000001',entryId='00000000-0000-4000-8000-000000000002';let effects=0;
|
||||
const {request,events}=await fixture(t,{actions:{'history:list':async()=>({snapshotId,entries:[{entryId,title:'Public',url:'https://example.test/',active:false,index:0,pageState:'PRIVATE_NATIVE_STATE'}]}),'history:go':async()=>{effects++;return {completed:true,url:'PRIVATE_TARGET'};},'window:state':async()=>({width:1200,height:800,visible:true,focused:true,minimized:false,maximized:false,fullScreen:false,title:'PRIVATE_WINDOW_TITLE'}),'window:control':async()=>{effects++;return {completed:true,state:{title:'PRIVATE_STATE'}};}}});
|
||||
const list=await request('history:list',{sessionId:'session'});assert.equal(list.status,200);const value=await list.json();assert.equal(JSON.stringify(value).includes('PRIVATE'),false);assert.equal(value.result.entries[0].index,undefined);
|
||||
assert.equal((await request('history:go',{sessionId:'session',snapshotId,entryId})).status,200);
|
||||
assert.equal((await request('window:state')).status,200);assert.equal((await request('window:control',{command:'maximize'})).status,200);
|
||||
for(const [action,args]of [['history:list',{}],['history:go',{sessionId:'session',snapshotId,entryId,index:1}],['history:go',{sessionId:'session',snapshotId:'invalid',entryId}],['window:control',{command:'close'}],['window:state',{title:'PRIVATE'}]])assert.equal((await request(action,args)).status,400);
|
||||
assert.equal(effects,2);assert.ok(events.some(e=>e.action==='history:go'&&e.scope==='history:go'&&e.outcome==='completed'));assert.ok(events.some(e=>e.action==='window:control'&&e.scope==='window:control'&&e.outcome==='completed'));assert.equal(JSON.stringify(events).includes('PRIVATE'),false);
|
||||
});
|
||||
test('postquantum channel refuses plaintext, tampering and replays before adapter execution',async t=>{
|
||||
const {api,calls}=await fixture(t);
|
||||
const headers=(route,body='{}')=>({'Content-Type':'application/json',Authorization:authorization(api.token,route,body)});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createAgentAdapters}=createRequire(import.meta.url)('../apps/browser/agent-adapters.cjs');
|
||||
function fixture(){
|
||||
let url='https://work.example/current',generation=0,history={activeIndex:1,entries:[{url:'https://work.example/old',title:'Old'},{url,title:'Current'}]},calls=0;
|
||||
const policy={version:1,agents:{enabled:true,scopes:['history:list','history:go'],allowedHosts:['work.example'],allowSensitiveRead:true}};
|
||||
const webContents={getURL:()=>url},context=()=>({ready:true,policy,sessionId:'session',foreground:true,token:generation+1,webContents,destinationAllowed:()=>true});
|
||||
const callbacks={getContext:context,getSessions:()=>[],contextCurrent:token=>token===generation+1,navigate:async()=>{},ai:async()=>{},inspect:async()=>({decision:'allow'}),audit:async()=>{},getHistory:()=>history,goHistory:async(index,initial,{historyBinding})=>{assert.equal(JSON.stringify(history),historyBinding);calls++;url=history.entries[index].url;generation++;return {completed:true,context:context()};}};
|
||||
return {callbacks,policy,history,actions:()=>createAgentAdapters(callbacks),calls:()=>calls,invalidate:()=>generation++};
|
||||
}
|
||||
test('history exports bounded metadata and opaque identifiers, navigates exact snapshot once',async()=>{
|
||||
const f=fixture(),a=f.actions(),list=await a['history:list']({});assert.equal(list.entries.length,2);assert.equal(list.entries[0].index,undefined);assert.match(list.snapshotId,/^[a-f0-9-]{36}$/);
|
||||
assert.deepEqual(await a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}),{completed:true});assert.equal(f.calls(),1);
|
||||
await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),1);
|
||||
});
|
||||
test('history masks credentials and DLP without sensitive override, discards native page state',async()=>{
|
||||
const f=fixture();f.history.entries[0]={url:'https://work.example/?access_token=private',title:'password=secret22',pageState:'private form'};f.callbacks.inspect=async()=>({decision:'block',ruleId:'corporate'});
|
||||
const list=await f.actions()['history:list']({});assert.equal(list.entries[0].title,'Protected page');assert.equal(list.entries[0].url,undefined);assert.equal(list.entries[1].url,undefined);assert.ok(!JSON.stringify(list).includes('private'));
|
||||
});
|
||||
test('history inspection errors, incomplete coverage and asynchronous replacement fail closed',async()=>{
|
||||
for(const verdict of [null,{decision:'allow',coverage:{complete:false}},{decision:'block',ruleId:'uninspectable'}]){const f=fixture();f.callbacks.inspect=async()=>verdict;await assert.rejects(f.actions()['history:list']({}),/Inspection/);}
|
||||
for(const mutate of [f=>f.history.entries[0].title='Changed',f=>f.invalidate(),f=>f.policy.agents.scopes=[]]){const f=fixture();f.callbacks.inspect=async()=>{mutate(f);return{decision:'allow'};};await assert.rejects(f.actions()['history:list']({}));}
|
||||
});
|
||||
test('history index replacement, authority revocation and foreign identifiers prevent effects',async()=>{
|
||||
for(const mutate of [f=>f.history.entries[0].url='https://work.example/replaced',f=>f.invalidate(),f=>f.policy.agents.scopes=['history:list']]){const f=fixture(),a=f.actions(),list=await a['history:list']({});mutate(f);await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);}
|
||||
const f=fixture(),a=f.actions(),list=await a['history:list']({});await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:'foreign'}));assert.equal(f.calls(),0);
|
||||
});
|
||||
test('history host restrictions exclude entries and cancellation prevents disclosure',async()=>{
|
||||
const f=fixture();f.history.entries[0].url='https://other.example/';assert.equal((await f.actions()['history:list']({})).entries.length,1);
|
||||
const g=fixture(),controller=new AbortController();g.callbacks.inspect=async()=>{controller.abort();return{decision:'allow'};};await assert.rejects(g.actions()['history:list']({}, {signal:controller.signal}));
|
||||
});
|
||||
test('history grants cannot cross agent identities or navigate masked credentials',async()=>{
|
||||
const f=fixture();let actor={agentId:'a',generation:1};const context=f.callbacks.getContext;f.callbacks.getContext=()=>({...context(),actor});
|
||||
const a=f.actions(),list=await a['history:list']({});actor={agentId:'b',generation:1};await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);
|
||||
const g=fixture();g.history.entries[0].url='https://work.example/?code=private';const b=g.actions(),masked=await b['history:list']({});await assert.rejects(b['history:go']({snapshotId:masked.snapshotId,entryId:masked.entries[0].entryId}));assert.equal(g.calls(),0);
|
||||
});
|
||||
test('encoded query keys and fragment credentials are masked and cannot be navigated',async()=>{
|
||||
for(const suffix of ['?%74oken=private','?%2574oken=private','#refresh_token=private','?sid=private','?client_secret=private']){
|
||||
const f=fixture();f.history.entries[0].url='https://work.example/'+suffix;const a=f.actions(),list=await a['history:list']({});assert.equal(list.entries[0].url,undefined);assert.equal(list.entries[0].title,'Protected page');await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);
|
||||
}
|
||||
});
|
||||
test('native page-state checksum stays private and detects same-title/URL replacement',async()=>{
|
||||
const f=fixture();f.history.nativeBinding='a'.repeat(64);const a=f.actions(),list=await a['history:list']({});assert.ok(!JSON.stringify(list).includes('a'.repeat(64)));f.history.nativeBinding='b'.repeat(64);await assert.rejects(a['history:go']({snapshotId:list.snapshotId,entryId:list.entries[0].entryId}));assert.equal(f.calls(),0);
|
||||
const g=fixture();g.history.nativeBinding='a'.repeat(64);g.callbacks.inspect=async()=>{g.history.nativeBinding='b'.repeat(64);return{decision:'allow'};};await assert.rejects(g.actions()['history:list']({}));
|
||||
});
|
||||
@@ -19,3 +19,14 @@ test('unexpected final URL and mainframe load failure reject; timeout stops',asy
|
||||
|
||||
test('missing positive native context proof denies history before any side effect',async()=>{const c=fixture();await assert.rejects(run(c,{current:async()=>undefined}),/context changed/);assert.equal(c.calls,0);});
|
||||
test('context guards distinguish captured state before dispatch from intentional navigation after',async()=>{const c=fixture(),phases=[];const p=run(c,{current:phase=>{phases.push(phase);return phase==='before'?c.calls===0:c.calls===1;}});await dispatched(c);c.emit('did-finish-load');assert.equal((await p).completed,true);assert.ok(phases.includes('before')&&phases.includes('after'));});
|
||||
|
||||
test('indexed history compares exact snapshot immediately before native dispatch and never retries',async()=>{
|
||||
const c=fixture(),snapshot=()=>({activeIndex:c.index,entries:[{url:'https://example.test/one',title:'One'},{url:'https://example.test/two',title:'Two'}]}),binding=JSON.stringify(snapshot());
|
||||
const p=run(c,{action:'index',index:0,getSnapshot:snapshot,historyBinding:binding});await dispatched(c);c.emit('did-finish-load');assert.equal((await p).url,'https://example.test/one');assert.equal(c.calls,1);
|
||||
const changed=fixture();let title='Two';const getSnapshot=()=>({activeIndex:changed.index,entries:[{url:'https://example.test/one',title:'One'},{url:'https://example.test/two',title}]});
|
||||
await assert.rejects(run(changed,{action:'index',index:0,getSnapshot,historyBinding:JSON.stringify(getSnapshot()),allowed:()=>{title='Replaced';return true;}}),/snapshot changed/);assert.equal(changed.calls,0);assert.equal(changed.eventNames().length,0);
|
||||
});
|
||||
test('indexed current-entry selection is a guarded no-op and raw indexes lack a snapshot authority',async()=>{
|
||||
const c=fixture(),snapshot=()=>({activeIndex:c.index,entries:[{url:'https://example.test/one',title:'One'},{url:'https://example.test/two',title:'Two'}]});assert.equal((await run(c,{action:'index',index:1,getSnapshot:snapshot,historyBinding:JSON.stringify(snapshot())})).completed,true);assert.equal(c.calls,0);
|
||||
for(const extra of [{index:-1},{index:0},{index:0,getSnapshot:snapshot},{index:0,getSnapshot:snapshot,historyBinding:'{}'}])await assert.rejects(run(c,{action:'index',...extra}));assert.equal(c.calls,0);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';
|
||||
const {readHistorySnapshot}=createRequire(import.meta.url)('../apps/browser/history-snapshot.cjs');
|
||||
function fixture(){const rows=[{url:'https://work.example/',title:'Work',pageState:'PRIVATE_FORM_VALUES',extra:'PRIVATE_NATIVE'}];return {rows,contents:{isDestroyed:()=>false,navigationHistory:{getAllEntries:()=>rows,getActiveIndex:()=>0}}};}
|
||||
test('native history strips form state and retains only a private exact-state checksum',()=>{
|
||||
const f=fixture(),first=readHistorySnapshot(f.contents);assert.deepEqual(first.entries,[{url:'https://work.example/',title:'Work'}]);assert.equal(JSON.stringify(first).includes('PRIVATE'),false);assert.match(first.nativeBinding,/^[a-f0-9]{64}$/);assert.equal(readHistorySnapshot(f.contents).nativeBinding,first.nativeBinding);
|
||||
f.rows[0].pageState='REPLACED_FORM_VALUES';assert.notEqual(readHistorySnapshot(f.contents).nativeBinding,first.nativeBinding);assert.deepEqual(readHistorySnapshot(f.contents).entries,first.entries);
|
||||
});
|
||||
test('unavailable or oversized native history cannot produce a comparison authority',()=>{
|
||||
for(const mutate of [f=>f.contents.isDestroyed=()=>true,f=>f.rows[0].pageState='x'.repeat(1048577),f=>f.rows[0].pageState={},f=>f.rows[0].title='x'.repeat(201),f=>f.contents.navigationHistory.getActiveIndex=()=>-1,f=>f.contents.navigationHistory.getAllEntries=()=>Array(101).fill(f.rows[0])]){const f=fixture();mutate(f);assert.throws(()=>readHistorySnapshot(f.contents));}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createWindowControls}=createRequire(import.meta.url)('../apps/browser/window-controls.cjs');
|
||||
function fixture(options={}){
|
||||
let destroyed=false,current=true,window,context;
|
||||
const calls=[],state={visible:true,focused:false,minimized:false,maximized:false,fullScreen:false};
|
||||
const contents={isDestroyed:()=>false};
|
||||
window={webContents:contents,isDestroyed:()=>destroyed,getBounds:()=>({x:-500,y:90,width:1280,height:800,title:'secret'}),...Object.fromEntries(Object.entries(state).map(([key])=>['is'+key[0].toUpperCase()+key.slice(1),()=>state[key]])),minimize(){calls.push('minimize');state.minimized=true;},maximize(){calls.push('maximize');state.maximized=true;},restore(){calls.push('restore');state.minimized=false;state.maximized=false;},focus(){calls.push('focus');state.focused=true;}};
|
||||
context={ready:true,policy:{version:1,agents:{enabled:true,scopes:['window:state','window:control']}}};
|
||||
const controls=createWindowControls({getWindow:()=>window,getContext:()=>context,isCurrent:()=>current,...options});
|
||||
return {controls,calls,state,get window(){return window;},get context(){return context;},replace:()=>window={...window},invalidate:()=>current=false,destroy:()=>destroyed=true};
|
||||
}
|
||||
test('window state exports only bounded dimensions and booleans',async()=>{
|
||||
const f=fixture();assert.deepEqual(await f.controls.state(),{width:1280,height:800,visible:true,focused:false,minimized:false,maximized:false,fullScreen:false});
|
||||
f.window.getBounds=()=>({width:Infinity,height:1});await assert.rejects(f.controls.state(),/dimensions/);
|
||||
f.window.getBounds=()=>({width:1,height:1});f.window.isVisible=()=> 'secret';await assert.rejects(f.controls.state(),/state/);
|
||||
});
|
||||
test('the four allowed commands execute once and return fresh state',async()=>{
|
||||
const f=fixture();for(const command of ['minimize','maximize','restore','focus'])assert.equal((await f.controls.control({command})).completed,true);
|
||||
assert.deepEqual(f.calls,['minimize','maximize','restore','focus']);assert.equal((await f.controls.state()).focused,true);
|
||||
});
|
||||
test('state and control scopes are independent and global disable denies both',async()=>{
|
||||
const f=fixture();f.context.policy.agents.scopes=['window:state'];await f.controls.state();await assert.rejects(f.controls.control({command:'focus'}),/scope/);
|
||||
f.context.policy.agents.scopes=['window:control'];await assert.rejects(f.controls.state(),/scope/);await f.controls.control({command:'focus'});
|
||||
f.context.policy.agents.enabled=false;await assert.rejects(f.controls.control({command:'focus'}),/scope/);assert.deepEqual(f.calls,['focus']);
|
||||
});
|
||||
test('no close, resize, arbitrary OS command, extra input or prototype access',async()=>{
|
||||
const f=fixture();for(const args of [{command:'close'},{command:'setBounds'},{command:'__proto__'},{command:'focus',path:'secret'},null,Object.create({command:'focus'})])await assert.rejects(f.controls.control(args),/Invalid/);
|
||||
await assert.rejects(f.controls.state({command:'focus'}),/Invalid/);assert.deepEqual(f.calls,[]);
|
||||
});
|
||||
test('expired and malformed policy, stale identity and destroyed native window deny effects',async()=>{
|
||||
for(const mutate of [f=>f.context.policy.expiresAt='2000-01-01',f=>f.context.policy.expiresAt='invalid',f=>f.context.policy.agents.scopes='window:control',f=>f.invalidate(),f=>f.destroy(),f=>f.context.ready=false]){
|
||||
const f=fixture();mutate(f);await assert.rejects(f.controls.control({command:'focus'}));assert.deepEqual(f.calls,[]);
|
||||
}
|
||||
});
|
||||
test('asynchronous authorization cannot release a stale policy, native window or cancellation',async()=>{
|
||||
for(const mutation of ['policy','window','cancel','grant']){
|
||||
const controller=new AbortController();let f,counter=0;f=fixture({authorize:async()=>{if(++counter===1){if(mutation==='policy')f.context.policy={...f.context.policy};if(mutation==='window')f.replace();if(mutation==='cancel')controller.abort();if(mutation==='grant')f.invalidate();}return true;}});
|
||||
await assert.rejects(f.controls.control({command:'focus'},{signal:controller.signal}));assert.deepEqual(f.calls,[]);
|
||||
}
|
||||
});
|
||||
test('an effect is not repeated when it revokes policy or cancellation suppresses output',async()=>{
|
||||
for(const mutation of ['policy','cancel']){
|
||||
const f=fixture(),controller=new AbortController();f.window.focus=()=>{f.calls.push('focus');if(mutation==='policy')f.context.policy.agents.enabled=false;else controller.abort();};
|
||||
await assert.rejects(f.controls.control({command:'focus'},{signal:controller.signal}));assert.deepEqual(f.calls,['focus']);
|
||||
}
|
||||
});
|
||||
test('fresh grant checks apply before the effect and native state getters cannot export after revocation',async()=>{
|
||||
let checks=0;const f=fixture({authorize:async()=>++checks<2});await assert.rejects(f.controls.control({command:'focus'}),/authorization/);assert.deepEqual(f.calls,[]);
|
||||
const g=fixture();g.window.getBounds=()=>{g.invalidate();return {width:1,height:1};};await assert.rejects(g.controls.state(),/changed/);
|
||||
});
|
||||
test('operation tracing wraps execution and failed native effects are never retried',async()=>{
|
||||
const names=[],f=fixture({traceOperation:async(name,fn)=>{names.push(name);return fn();}});await f.controls.state();f.window.focus=()=>{f.calls.push('focus');throw Error('sensitive native failure');};await assert.rejects(f.controls.control({command:'focus'}),/^Error: Managed window operation failed$/);assert.deepEqual(names,['window:state','window:control']);assert.deepEqual(f.calls,['focus']);
|
||||
});
|
||||
Reference in new issue
Block a user