diff --git a/README.md b/README.md
index fef6fc8..4613ebf 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ S Browser uses translucent materials, rounded controls, light/dark themes, and a
| 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, 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. |
+| Additional controls | Local encrypted password vault with explicit autofill and separately approved agent status/masked inventory/fill after human unlock; protected-brand visual similarity checks paired with trusted HTTPS origins. These features require further independent evaluation. |
Individual agent mode currently blocks browser-session requests without a known guarded tab owner, including unattributed background-worker traffic. Reliable agent ownership for these requests remains open. See [individual agent administration](docs/individual-agent-grants.md) and [agent API](AGENT-API.md).
diff --git a/apps/browser/agent-adapters.cjs b/apps/browser/agent-adapters.cjs
index f2c9c3d..138086d 100644
--- a/apps/browser/agent-adapters.cjs
+++ b/apps/browser/agent-adapters.cjs
@@ -153,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=['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 appActions=['vault:status','vault:list','vault:fill','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}={})=>{
diff --git a/apps/browser/agent-control.cjs b/apps/browser/agent-control.cjs
index 8a9adfe..2851ee8 100644
--- a/apps/browser/agent-control.cjs
+++ b/apps/browser/agent-control.cjs
@@ -3,7 +3,7 @@ const http = require('node:http');
const crypto = require('node:crypto');
const channel=require('../../packages/core/policy-channel.cjs');
-const APP_ACTIONS=Object.freeze(['history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']);
+const APP_ACTIONS=Object.freeze(['vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']);
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
const fail = (status, message) => Object.assign(new Error(message), { status });
const bounded = (value, max, name) => {
@@ -16,9 +16,13 @@ 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 = { 'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
+ const keys = { 'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
const clean = {};
+ if(action.startsWith('vault:')){
+ if(args.sessionId!==undefined||action==='vault:fill')clean.sessionId=bounded(args.sessionId,128,'sessionId');
+ if(action==='vault:fill'){clean.credentialRef=bounded(args.credentialRef,36,'credentialRef');if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean.credentialRef))throw fail(400,'Invalid credential reference');}
+ }
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');}
@@ -55,6 +59,15 @@ function validate(body) {
}
// Never return adapter objects wholesale: session handles and sanitized text only.
function project(action, result) {
+ if(action==='vault:status'){
+ if(!result||!['exists','locked','enabled','allowAutofill'].every(key=>typeof result[key]==='boolean'))throw fail(502,'Invalid vault response');
+ return Object.fromEntries(['exists','locked','enabled','allowAutofill'].map(key=>[key,result[key]]));
+ }
+ if(action==='vault:list'){
+ if(!Array.isArray(result)||result.length>32)throw fail(502,'Invalid vault response');
+ return result.map(row=>{let origin;try{origin=new URL(row.origin);}catch{throw fail(502,'Invalid vault response');}if(typeof row.credentialRef!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(row.credentialRef)||typeof row.origin!=='string'||row.origin.length>2048||origin.protocol!=='https:'||origin.username||origin.password||origin.origin!==row.origin)throw fail(502,'Invalid vault response');return {credentialRef:row.credentialRef,origin:row.origin};});
+ }
+ if(action==='vault:fill')return {completed:result?.completed===true};
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');
diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs
index d0bd431..090c00d 100644
--- a/apps/browser/diagnostics.cjs
+++ b/apps/browser/diagnostics.cjs
@@ -14,6 +14,8 @@ const names=new Set(['browser.window','browser.network','browser.ipc','browser.a
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);
+names.add('browser.vault');
+for(const operation of ['vault:status','vault:list','vault:fill','vault.inventory.inspect','vault.fill.visual','vault.fill.native'])operations.add(operation);
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);
diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs
index baef57c..7b76442 100644
--- a/apps/browser/main.cjs
+++ b/apps/browser/main.cjs
@@ -395,25 +395,29 @@ async function applyLibraryOrganization(input,options){
const result=await getLibraryOrganizer().apply(input,options);
await sync();return result;
}
+async function inspectManagedAgentText(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;
+ if(context.policy.dlp.semantic.enabled||context.policy.security.yara.enabled){
+ if(!localInspector)return {decision:'block',ruleId:'inspection-unavailable',coverage:{complete:false}};
+ const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy));
+ verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result;
+ }
+ if(verdict.decision!=='allow')return verdict;
+ const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external;
+}
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,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;
- if(context.policy.dlp.semantic.enabled||context.policy.security.yara.enabled){
- if(!localInspector)return {decision:'block'};const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy));
- verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result;
- }if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external;
- },audit:auditAgent,onInteraction:async metadata=>{
+ inspect:inspectManagedAgentText,audit:auditAgent,onInteraction:async metadata=>{
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'agent-adapter'},policy));if(!payload)return;
const actor=agentActorStorage.getStore();await auditAgent({action:'click',outcome:'completed',sessionId:metadata.sessionId,...(actor?.principalId?{principalId:actor.principalId,generation:actor.generation}:{}),protectedInteraction:payload});
}});
for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};}
for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{
- if(context.principalId&&['navigate','back','forward','reload','history:go','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','vault:fill','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
return original(args,context);
}));}
agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[],
@@ -641,11 +645,19 @@ function groupNativeTab(input){requireManagedSection('library');if(libraryOrgani
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 vaultControls=require('./vault-agent-controls.cjs').createVaultAgentControls({
+ getContext:id=>{const context=agentContext(id);return {...context,ready:context.ready&&featureAllowed(managedSettings(),'passwords'),actor:context.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};},
+ contextCurrent:currentTabContext,getVault:requireVault,checkVisual:context=>checkVisual(tabs.get(context.sessionId)),inspect:inspectManagedAgentText,
+ audit:event=>audit(event.action),traceOperation:(operation,fn)=>diagnosticSpan('browser.vault',{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 {
+ 'vault:status':checked((args,options)=>vaultControls.status(args,options)),
+ 'vault:list':checked((args,options)=>vaultControls.list(args,options)),
+ 'vault:fill':checked((args,options)=>vaultControls.fill(args,options)),
'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})),
diff --git a/apps/browser/vault-agent-controls.cjs b/apps/browser/vault-agent-controls.cjs
new file mode 100644
index 0000000..c2db7a2
--- /dev/null
+++ b/apps/browser/vault-agent-controls.cjs
@@ -0,0 +1,70 @@
+'use strict';
+const {randomUUID}=require('node:crypto');
+const {fillScript}=require('./password-fill.cjs');
+const refused=()=>{throw Error('Managed vault operation refused');};
+const idPattern=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const hostMatch=(name,pattern)=>pattern.startsWith('*.')?name.endsWith(pattern.slice(1))&&name!==pattern.slice(2):name===pattern;
+function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisual,inspect,audit,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}) {
+ if([getContext,contextCurrent,getVault,checkVisual,inspect,audit,traceOperation,now].some(fn=>typeof fn!=='function'))throw TypeError('Vault boundary callbacks required');
+ const references=new Map();
+ const allowed=(origin,c)=>{
+ let u;try{u=new URL(origin);}catch{return false;}
+ const hosts=c.policy.agents.allowedHosts;
+ return u.protocol==='https:'&&!u.username&&!u.password&&u.origin===origin&&Array.isArray(hosts)&&(!hosts.length||hosts.some(p=>typeof p==='string'&&hostMatch(u.hostname.toLowerCase(),p.toLowerCase())))&&c.destinationAllowed(origin,c.policy)===true;
+ };
+ function snapshot(args,signal,action){
+ const fields=action==='vault:fill'?['sessionId','credentialRef']:['sessionId'];
+ if(!args||typeof args!=='object'||Array.isArray(args)||Object.keys(args).some(key=>!fields.includes(key))||(args.sessionId!==undefined&&!idPattern.test(args.sessionId)))refused();
+ const c=getContext(args.sessionId);
+ if(signal?.aborted||!c?.ready||!c.token||(!c.actor||typeof c.actor.agentId!=='string'||!c.actor.agentId||!Number.isSafeInteger(c.actor.generation))||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||!c.policy.passwordManager?.enabled||!Number.isFinite(Date.parse(c.policy.expiresAt))||Date.parse(c.policy.expiresAt)<=now()||typeof c.destinationAllowed!=='function')refused();
+ return {...c,action};
+ }
+ function check(c,args,signal){
+ const next=snapshot(args,signal,c.action);
+ if(next.policy!==c.policy||next.actor.agentId!==c.actor.agentId||next.actor.generation!==c.actor.generation||!contextCurrent(c.token)||next.sessionId!==c.sessionId||next.webContents!==c.webContents)refused();
+ return next;
+ }
+ function unlocked(c){const vault=getVault();if(!vault||typeof vault.accessGeneration!=='function'||vault.status().locked)refused();return vault;}
+ function currentVault(c,args,signal,vault){check(c,args,signal);if(getVault()!==vault||vault.status().locked)refused();}
+ const status=async(args={}, {signal}={})=>{
+ const c=snapshot(args,signal,'vault:status'),s=getVault().status();check(c,args,signal);
+ return {exists:s.exists===true,locked:s.locked!==false,enabled:true,allowAutofill:c.policy.passwordManager.allowAutofill===true};
+ };
+ const list=async(args={}, {signal}={})=>{
+ const c=snapshot(args,signal,'vault:list'),vault=unlocked(c),generation=vault.accessGeneration(),rows=vault.list();
+ if(!Array.isArray(rows)||rows.length>1000)refused();
+ const safe=rows.filter(row=>row&&idPattern.test(row.id)&&allowed(row.origin,c)).map(row=>({id:row.id,origin:row.origin}));
+ const verdict=await traceOperation('vault.inventory.inspect',()=>inspect(safe.map(row=>row.origin).join('\n'),c));
+ currentVault(c,args,signal,vault);
+ if(vault.accessGeneration()!==generation)refused();
+ const currentRows=vault.list();
+ if(!Array.isArray(currentRows)||JSON.stringify(currentRows.map(row=>({id:row.id,origin:row.origin})))!==JSON.stringify(rows.map(row=>({id:row.id,origin:row.origin}))))refused();
+ if(verdict?.decision!=='allow'||verdict.coverage?.complete===false||/^(?:uninspectable|size-limit|inspection-incomplete|unavailable|timeout)$/.test(verdict.ruleId||''))refused();
+ // A denied export creates no usable references. References expire and cannot cross actors/policies.
+ for(const [key,row]of references)if(row.expires<=now()||!contextCurrent(row.token))references.delete(key);
+ if(references.size+Math.min(safe.length,32)>32)refused();
+ return safe.slice(0,32).map(row=>{const credentialRef=randomUUID();references.set(credentialRef,{...row,sessionId:c.sessionId,webContents:c.webContents,actor:{...c.actor},generation:vault.accessGeneration(),policy:c.policy,token:c.token,vault,expires:now()+60000});return {credentialRef,origin:row.origin};});
+ };
+ const fill=async(args={}, {signal}={})=>{
+ let credential;
+ try{
+ const c=snapshot(args,signal,'vault:fill'),vault=unlocked(c),ref=references.get(args.credentialRef);
+ if(!idPattern.test(args.credentialRef||'')||!ref||ref.sessionId!==c.sessionId||ref.webContents!==c.webContents||ref.expires<=now()||(ref.actor.agentId!==c.actor.agentId||ref.actor.generation!==c.actor.generation)||ref.generation!==vault.accessGeneration()||ref.policy!==c.policy||ref.vault!==vault||!contextCurrent(ref.token)||c.foreground!==true||c.interactive===false||!c.policy.passwordManager.allowAutofill||!c.webContents||c.webContents.isDestroyed?.())refused();
+ const url=c.webContents.getURL(),u=new URL(url);
+ if(u.username||u.password||u.protocol!=='https:'||u.origin!==ref.origin||!allowed(ref.origin,c))refused();
+ const recheck=()=>{const next=check(c,args,signal);currentVault(c,args,signal,vault);if(vault.accessGeneration()!==ref.generation||next.foreground!==true||next.interactive===false||!next.policy.passwordManager.allowAutofill||c.webContents.isDestroyed?.()||c.webContents.getURL()!==url||ref.expires<=now())refused();};
+ if((await traceOperation('vault.fill.visual',()=>checkVisual(c)))?.decision!=='allow')refused();recheck();
+ await audit({action:'vault.fill-requested',outcome:'requested',sessionId:c.sessionId});recheck();
+ // Consume once before the native effect: failures never authorize an automatic retry.
+ references.delete(args.credentialRef);
+ credential={...vault.credential(ref.id,ref.origin),origin:ref.origin};recheck();
+ c.webContents.focus();recheck();
+ const result=await traceOperation('vault.fill.native',()=>c.webContents.executeJavaScriptInIsolatedWorld(999,[{code:fillScript(url,credential,Date.parse(c.policy.expiresAt))}],false));
+ recheck();if(result?.completed!==true)refused();
+ await audit({action:'vault.filled',outcome:'completed',sessionId:c.sessionId});recheck();return {completed:true};
+ }catch{refused();}finally{if(credential){credential.username='';credential.password='';credential.origin='';}}
+ };
+ const safe=fn=>async(...args)=>{try{return await fn(...args);}catch{refused();}};
+ return {status:safe(status),list:safe(list),fill:safe(fill),clear:()=>references.clear()};
+}
+module.exports={createVaultAgentControls};
diff --git a/apps/browser/vault.cjs b/apps/browser/vault.cjs
index 778665b..e836e8a 100644
--- a/apps/browser/vault.cjs
+++ b/apps/browser/vault.cjs
@@ -49,11 +49,19 @@ function generatePassword(length = 32) {
}
function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) {
if (typeof filePath !== 'string' || !path.isAbsolute(filePath) || typeof protect !== 'function' || typeof unprotect !== 'function' || !Number.isInteger(idleSeconds) || idleSeconds < 60 || idleSeconds > 900) fail();
- let key = null, entries = [], envelope = null, timer = null;
+ let key = null, entries = [], envelope = null, timer = null, accessGeneration = 0;
+ // Process-local capability epoch. Never persisted or included in public metadata.
+ // create/unlock begin with lock, so even reopening the same vault invalidates refs.
+ function invalidateAccess() {
+ if (accessGeneration >= Number.MAX_SAFE_INTEGER) { lock(); fail(); }
+ accessGeneration += 1;
+ }
function lock() {
clearTimeout(timer); timer = null; wipe(key); key = null;
for (const entry of entries) { entry.password = ''; entry.username = ''; entry.label = ''; entry.origin = ''; }
entries = []; envelope = null;
+ if (accessGeneration >= Number.MAX_SAFE_INTEGER) fail();
+ accessGeneration += 1;
}
function touch() { clearTimeout(timer); timer = setTimeout(lock, idleSeconds * 1000); timer.unref(); }
function requireUnlocked(refreshIdle = true) { if (!key) fail(); if (refreshIdle) touch(); }
@@ -136,7 +144,7 @@ function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) {
function status() { return { exists: fs.existsSync(filePath), locked: !key, idleSeconds, algorithm: 'AES-256-GCM / ML-KEM-1024 / scrypt / Windows DPAPI' }; }
function list() { requireUnlocked(false); return entries.map(({ id, origin, username, label }) => ({ id, origin, username, label })); }
function mutate(action) {
- requireUnlocked(); const previous = entries.map(entry => ({ ...entry }));
+ requireUnlocked(); invalidateAccess(); const previous = entries.map(entry => ({ ...entry }));
try { const result = action(); persist(); return result; } catch { entries = previous; fail(); }
}
function upsert(entry) {
@@ -156,6 +164,6 @@ function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) {
if (!entry || entry.origin !== expected) fail();
return { username: entry.username, password: entry.password };
}
- return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword };
+ return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword, accessGeneration: () => accessGeneration };
}
module.exports = { createVault, generatePassword };
diff --git a/apps/server/public/admin.js b/apps/server/public/admin.js
index b92b248..7ea6c55 100644
--- a/apps/server/public/admin.js
+++ b/apps/server/public/admin.js
@@ -6,7 +6,7 @@ let credential='',policy,presetPreview,managedBookmarks=[],personalBookmarks=[],
const managedSections=['appearance','library','ai','passwords','documentScan','agents'];
-const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control'];
+const agentScopes=['sessions','navigate','back','forward','reload','read','click','type','select','check','screenshot','ai','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply','inspect','scroll','history:list','history:go','window:state','window:control','vault:status','vault:list','vault:fill'];
function status(message,error=false){$('status').textContent=message;$('status').className=error?'error':'';}
diff --git a/apps/server/public/agent-grants-admin.js b/apps/server/public/agent-grants-admin.js
index a86e779..6250d74 100644
--- a/apps/server/public/agent-grants-admin.js
+++ b/apps/server/public/agent-grants-admin.js
@@ -13,7 +13,7 @@ function renderAgentGrants(){
const enabled=agentGrantField(grid,'Enabled');enabled.type='checkbox';enabled.checked=grant.enabled;enabled.disabled=!agentGrantsEditable;enabled.addEventListener('change',()=>{grant.enabled=enabled.checked;changedAgentGrants();});
const expiry=agentGrantField(grid,'Expiry (UTC)');expiry.type='text';expiry.value=grant.expiresAt;expiry.placeholder='2026-12-31T23:59:59Z';expiry.disabled=!agentGrantsEditable;expiry.addEventListener('input',()=>{grant.expiresAt=expiry.value.trim();changedAgentGrants();});
const hosts=agentGrantField(grid,'Additional destination allowlist','textarea');hosts.value=grant.allowedHosts.join('\n');hosts.placeholder='Empty adds no destination restriction';hosts.disabled=!agentGrantsEditable;hosts.addEventListener('input',()=>{grant.allowedHosts=hosts.value.split('\n').map(v=>v.trim()).filter(Boolean);changedAgentGrants();});
- const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox);
+ const scopeBox=document.createElement('fieldset');const scopeLegend=document.createElement('legend');scopeLegend.textContent='Additional capability limit';scopeBox.append(scopeLegend);const scopeHelp=document.createElement('p');scopeHelp.className='muted';scopeHelp.textContent='History reads and navigation require separate capabilities. Window controls are limited to supported browser operations. Vault status, masked listing and exact-origin autofill require separate capabilities and human unlock. Agents cannot unlock the vault or export passwords. All granted capabilities remain restricted by the central baseline, destination rules and DLP.';scopeBox.append(scopeHelp);const scopes=document.createElement('div');scopes.className='checks';scopeBox.append(scopes);box.append(scopeBox);
for(const scope of agentScopes){const label=document.createElement('label');const check=document.createElement('input');check.type='checkbox';check.checked=grant.scopes.includes(scope);check.disabled=!agentGrantsEditable;label.append(check,document.createTextNode(scope));scopes.append(label);check.addEventListener('change',()=>{grant.scopes=agentScopes.filter(s=>s===scope?check.checked:grant.scopes.includes(s));changedAgentGrants();});}
const actions=document.createElement('div');actions.className='row';const generation=document.createElement('span');generation.textContent=`Credential generation: ${grant.generation}`;const rotate=document.createElement('button');rotate.type='button';rotate.className='secondary';rotate.textContent='Invalidate previous credentials';rotate.disabled=!agentGrantsEditable||grant.generation>=Number.MAX_SAFE_INTEGER;rotate.addEventListener('click',()=>{grant.generation+=1;renderAgentGrants();changedAgentGrants();agentGrantStatus('Credential generation changed in draft. Publish after review, then pair this identity again locally.');});const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='Revoke grant';revoke.disabled=!agentGrantsEditable||!grant.enabled;revoke.addEventListener('click',()=>{grant.enabled=false;renderAgentGrants();changedAgentGrants();agentGrantStatus('Grant disabled in draft. Publish after review to apply revocation.');});actions.append(generation,rotate,revoke);
if(!agentGrantsPublishedIds.has(grant.id)){const remove=document.createElement('button');remove.type='button';remove.className='secondary';remove.textContent='Remove unpublished grant';remove.disabled=!agentGrantsEditable;remove.addEventListener('click',()=>{agentGrantsDraft=agentGrantsDraft.filter(g=>g!==grant);renderAgentGrants();changedAgentGrants();});actions.append(remove);}
diff --git a/apps/server/public/index.html b/apps/server/public/index.html
index 240edb0..3e10b9e 100644
--- a/apps/server/public/index.html
+++ b/apps/server/public/index.html
@@ -12,7 +12,7 @@
IDENTITY AND FEATURE ACCESS
User classes and website access
Create profiles for any user class with your own names and controls. Employee, developer and high-protection templates are optional starting points. Assign profiles to verified enterprise subjects or explicit managed devices. Asset-owner labels are not authenticated identities. Website denials and category restrictions accumulate across matching profiles; an allowlist does not bypass a denial.
Profile
Assignments
Developer tools
Action
Create profile
Developer grants require central delegation; any applicable deny wins. No profile can override mandatory scanning or unlock centrally hidden settings.
Preview effective device policy
Central categories and developer delegation
Categories contain your approved domain lists. This is not an automatic external website reputation feed. Enabling delegation permits reviewed developer-profile grants; denying profiles still win.
Website categories
Use exact hosts or wildcard subdomains such as *.social.example. Changes stay in this local draft until explicit publication or independent approval.
Assignment visibility
Device
Verified subject
Effective profiles
CONTROLLED DEPLOYMENT
Groups and pilot rollouts
Apply additional restrictions to explicit device groups. Start with selected pilot devices, inspect their reported policy versions, then explicitly expand the cohort. Group rules can tighten the baseline; they cannot weaken it. No automatic promotion occurs.
Group
Members
Stage
Actions
Create a device group
Membership, restrictions and cohort changes are signed policy changes. Mandatory independent approval applies to these changes too.
Additional company bookmarks
These managed bookmarks are added for the selected rollout cohort. They do not replace baseline company bookmarks.
Title
URL
Folder
Action
Effective policy preview
Reported rollout state
Device
Effective groups
Reported / target version
Version acknowledgment
Last contact
Acknowledgment compares the device-reported version with its target. It is not security attestation or proof that every control is functioning. Last contact is shown separately.
INDEPENDENT CHANGE CONTROL
Policy approval queue
Loading approval requirements.
Prepared changes remain inactive until approved. A different administrator must review the exact payload and base version. Stale changes must be prepared again against the latest policy.
Prepared
Author
Change
Base version
Status
Review
CHANGE CONTROL
Policy history and recovery
Review persisted signed versions and compare changes. Restoring reapplies the selected security rules, permissions, bookmarks and network settings, group memberships and rollout cohorts. The original version stays preserved; restoration publishes a new version with a fresh expiry. Devices never receive a lower policy version.
Version
Published
Actor
Review
Managed browser settings
Hide settings or deny changes centrally. These permissions are enforced in the native browser process as well as the interface. Hiding a section denies its user actions; publishing a new signed policy applies changes to connected devices at refresh.
Section
Hide and deny access
Lock user actions
Appearance
Bookmarks and tab groups
AI workspace
Password manager
Manual document scan
Local agent controls
Appearance values become mandatory when appearance is hidden, locked, or settings access is disabled. Otherwise users may choose local preferences. Vault locking remains available to keep credentials safe. Mandatory security inspection continues independently of settings visibility. Publish signed policy above to apply.
COMPANY LIBRARY
Managed bookmarks
Publish intranet and approved service links to every browser. Company bookmarks are read-only for employees. Personal bookmarks remain separate; configure whether employees may add, edit or delete them.
Title
HTTPS / HTTP URL (no query or fragment)
Folder
Actions
Company changes are drafts until you publish the signed policy. Personal permissions also respect the centrally locked or hidden Library section.
Device personal bookmarks
Authorized administrators can review and remotely edit a device's synchronized personal bookmarks. Changes are applied when the browser next refreshes management state. Titles and URLs are never included in security audit events.
Title
HTTPS / HTTP URL (no query or fragment)
Folder
Actions
Saving compares the loaded revision so another user's or administrator's updates cannot be overwritten silently.
TEACH YOUR BROWSER
Define what must stay inside
Add phrases that must never leave, or register an exact file fingerprint. Reference files stay on your computer; only their SHA-256 fingerprint enters policy.
Local semantic reference protection
Describe a confidential category. Your approved local EmbeddingGemma 2 runtime creates pinned text reference vectors. Document extraction and local OCR text can be compared against these references. This workflow does not train a model or provide native video inspection.
Only normalized reference vectors and coverage features enter the policy draft. No document, extracted text or model fine tuning is retained. Complete extraction and chunk coverage are required; publish explicitly to approve.
Requires locally provisioned model weights, an exact model revision, matching dimensions and the document-v1 profile. Similarity is a detector score, not a confidence guarantee. Incomplete extraction or chunk coverage always blocks.
-
Enterprise security and local agents
Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.
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.
Individual agent identities
Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.
Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.
Advanced source configuration: YARA, Sigma and visual references
Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.
Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.
Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.
+
Enterprise security and local agents
Configure centrally signed DNS blocklists, YARA source rules, content phrases, restricted Sigma metadata detections and agent permissions. Unsupported Sigma syntax is rejected.
History read and history navigation are separate capabilities. History navigation remains subject to destination rules and DLP. Window state exposes bounded browser state; window control is limited to supported browser window operations and never grants OS or administrator access. Enable only the capabilities each agent requires.
Vault status, masked listing and autofill are separate capabilities. A person must unlock the local vault; agents cannot unlock it or export passwords. Listing and filling are restricted to the current page and the credential's exact origin. Autofill also requires the central vault and autofill permissions. Grant only the capabilities an agent needs.
Individual agent identities
Individual mode requires an enrolled local identity matching an enabled, unexpired grant. Grant scopes and destinations further restrict the central baseline. Credentials stay on the device; no private keys or tokens are stored here. Revocation and generation changes take effect after the new signed policy reaches the browser.
Changes remain in this draft. Publish with the usual policy review. Disabling a grant revokes it; advancing its generation invalidates previously paired credentials and requires local pairing again. Empty capabilities permit no actions.
Advanced source configuration: YARA, Sigma and visual references
Structured controls above are authoritative for DNS, content and agent permissions. Preserve advanced rule sources when making other changes.
Agents require paired keys and authenticated post-quantum sessions; capabilities are further restricted by each token, destination rules and DLP. Disable paired local agents and publish to revoke control. Agent permissions never grant administrator privileges or unlock centrally hidden settings.
Publish signed policy above to apply. Downloads require configured local YARA or external ICAP. Every enabled scanner is mandatory; unavailable inspection blocks downloads. Scan limit is 20 MB. Sigma supports exact metadata selection only.
Governed document teaching
Submit a reference document to the private local inspector, then explicitly approve its fingerprint for publication. For incomplete extraction, explicitly approve available fingerprints with partial coverage; unextracted media is not fingerprinted. Only hashes, coverage counts and the reference label are retained. Full documents, extracted text and base64 are discarded after local processing and are never persisted by management.
Local password vault
Credentials stay on the browser device. Management stores permissions and lifecycle audit events only; passwords, usernames and credential identifiers are not uploaded.
Publish signed policy to apply these controls.
Visual impersonation protection
For example, register a trusted Google login screenshot and allow only https://accounts.google.com. A matching login on another origin can then be blocked even when its domain is absent from every blocklist.
Upload your own trusted PNG of a brand login page or cropped login form. Only a derived perceptual hash, dimensions and allowed origins enter the policy draft. Reference pixels are discarded; no automatic Google or other brand profile is supplied.
Add trusted references, enable the control above and publish the signed policy. Use multiple references for different themes, languages and login steps. False positives, viewport changes and visual evasion require validation. The browser checks rendered pixels; this is not pre-render response inspection.
Managed clipboard and ClickFix protection
Control supported browser copy, cut and paste actions centrally. High-risk shell command patterns are blocked without sending clipboard text to an AI provider. Clipboard text and credentials never enter security logs.
Publish signed policy to apply. Mandatory DLP remains active when supplemental clipboard controls are disabled. Unsupported formats and unavailable required inspection fail closed. Pattern detection covers selected execution chains; it does not prove text or commands safe.
DEVICE FLEET
Enrollment and access
Enable or disable supplemental manual document analysis per browser. Mandatory upload DLP and download YARA continue regardless of this setting.
Device
Platform
Identity
Policy
Last seen
Access
ADMINISTRATOR ACCESS
Active management sessions
Administrators can review current SSO sessions and immediately revoke a session. Identity-provider group membership controls whether that person can sign in again. Bootstrap access has no stored browser session.
Session inventory requires the administrator role.
Identity
Role
Expires
Action
SECURITY OPERATIONS
Incident triage
Review security event metadata, assign an investigation and record its outcome. Protected evidence is shown separately only with explicit, audited access. Keep investigation notes free of secrets and personal data.
Created
Security event
Status
Severity
Assignee
Review
Incident
Device-reported protected evidence
Investigation history
AUDIT LIFECYCLE
Database event retention
Loading deployment configuration.
Retention applies only to exported database events that are not referenced by incidents. Investigation history is retained. External SIEM, file exports and backups have separate retention policies. Deployment configuration controls this setting.
SECURITY MONITORING
Recent audit events
Time
Event
Device
Metadata
This view shows the latest 100 events. Prompts and document contents are excluded. Exports contain up to 1,000 events; cursor-based APIs and durable HTTPS delivery support continuous SIEM ingestion.
COMPANY EXPERIENCE
Company logo and start page
Roll out your company identity through signed policy. Name and accent above apply alongside this logo.
Logo pixels are re-encoded locally; SVG is not accepted. News is retrieved by management only from deployment-approved feed hosts. Ask your server operator to allow the exact host. Feed URLs cannot contain credentials or fragments. Use server-configured authentication; do not put secrets in URLs. Publish and review these changes with your normal policy workflow.
Download inspection
Document DLP is required by default. Disabling it permits incoming documents after every enabled malware scanner approves them; upload protection remains enforced. Without any antivirus scanner, complete document DLP is still mandatory. Office macros are not executed. Unreadable or unsupported macro extraction blocks when local macro scanning is enabled. ICAP remains mandatory whenever enabled.
AUDIT INTEGRITY
Verify security audit records
Run an explicit consistency and signature check. This check is separate from OpenTelemetry traces and SIEM delivery.
Not verified in this session.
PROTECTED SECURITY EVIDENCE
Protected evidence collection
Opt in through centrally signed policy. All collection controls are off by default. Ordinary security audit records and OpenTelemetry traces remain separate.
Download evidence contains the source URL without its query or fragment, filename, SHA-256, byte count, MIME type and verdict. It does not contain the file body or the OS save path. Passwords and recognizable authentication credentials are excluded. A deployment-protected evidence key is required before enabling recording.
Disabling recording prevents new device evidence writes and removes pending native evidence payloads; ordinary security events remain. Existing encrypted evidence retains its access and retention controls. Interaction capture records the website origin, session, action and bounded target metadata. Guarded clipboard and agent effects are marked completed; trusted foreground page clicks and activated form events are marked observed, which does not prove network transmission. Sensitive forms, page paths, values, queries and fragments are excluded. It excludes clipboard contents, entered values and control labels. Collection is bounded and limited to the active main-frame page; it is not a complete replay. Publish and review this draft through the normal policy workflow.
OBSERVABILITY
Browser OpenTelemetry
Publish centrally signed operational or detailed debug collection. Spans report code stages, timing and outcomes. Confidential security evidence needs its separate protected storage; passwords and authentication tokens are excluded.
HTTPS is required except for a numeric loopback collector. Queue and export limits remain enforced. Browser logs and traces use OTLP. Durable security events are separately delivered by the management service in ECS or CEF; its HTTPS destination, TLS certificates and credentials are configured by the server operator, not exposed in browser policy. Backend service traces use the server OpenTelemetry configuration. Endpoint credentials do not belong in policy URLs. Disable and publish to stop new collection.